diff --git a/README.md b/README.md index e7f756fc..102a252c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# XMTP-iOS +# xmtp-ios -![Lint](https://github.com/xmtp/xmtp-ios/actions/workflows/lint.yml/badge.svg) ![Status](https://img.shields.io/badge/Project_Status-Production-31CA54) +![Test](https://github.com/xmtp/xmtp-ios/actions/workflows/test.yml/badge.svg) ![Lint](https://github.com/xmtp/xmtp-ios/actions/workflows/lint.yml/badge.svg) ![Status](https://img.shields.io/badge/Feature_status-Alpha-orange) `xmtp-ios` provides a Swift implementation of an XMTP message API client for use with iOS apps. @@ -12,261 +12,203 @@ To learn more about XMTP and get answers to frequently asked questions, see the ![x-red-sm](https://user-images.githubusercontent.com/510695/163488403-1fb37e86-c673-4b48-954e-8460ae4d4b05.png) -## Quickstart and example apps built with `xmtp-ios` +## Example app built with `xmtp-ios` -- Use the [XMTP iOS quickstart app](https://github.com/xmtp/xmtp-ios/tree/main/XMTPiOSExample/XMTPiOSExample) as a tool to start building an app with XMTP. This basic messaging app has an intentionally unopinionated UI to help make it easier for you to build with. +Use the [XMTP iOS quickstart app](https://github.com/xmtp/xmtp-ios/tree/main/example) as a tool to start building an app with XMTP. This basic messaging app has an intentionally unopinionated UI to help make it easier for you to build with. -- Use the [XMTP Inbox iOS example app](https://github.com/xmtp-labs/xmtp-inbox-ios) as a reference implementation to understand how to implement features following developer and user experience best practices. +To learn about example app push notifications, see [Enable the quickstart app to send push notifications](library/src/main/java/org/xmtp/ios/library/push/README.md). ## Reference docs -> **View the reference** -> Access the [Swift client SDK reference documentation](https://xmtp.github.io/xmtp-ios/documentation/xmtp). +> **View the reference** +> Access the [Swift client SDK reference documentation](https://xmtp.github.io/xmtp-ios/). -## Install with Swift Package Manager +## Install from Swift Package Manager -Use Xcode to add to the project (**File** > **Add Packages…**) or add this to your `Package.swift` file: - -```swift -.package(url: "https://github.com/xmtp/xmtp-ios", branch: "main") -``` +You can add XMTP-iOS via Swift Package Manager by adding it to your `Package.swift` file or using Xcode’s “Add Package Dependency” feature. ## Usage overview -The XMTP message API revolves around a message API client (client) that allows retrieving and sending messages to other XMTP network participants. A client must connect to a wallet app on startup. If this is the very first time the client is created, the client will generate a key bundle that is used to encrypt and authenticate messages. The key bundle persists encrypted in the network using an account signature. The public side of the key bundle is also regularly advertised on the network to allow parties to establish shared encryption keys. All of this happens transparently, without requiring any additional code. +The XMTP message API revolves around a message API client (client) that allows retrieving and sending messages to other XMTP network participants. A client must connect to a wallet app on startup. If this is the very first time the client is created, the client will generate an identity with an encrypted local database to store and retrieve messages. Each additional log in will create a new installation if a local database is not present. ```swift -import XMTPiOS - // You'll want to replace this with a wallet from your application. -let account = try PrivateKey.generate() +let account = PrivateKey() + +// A key to encrypt the local database +let encryptionKey = SymmetricKey(size: .bits256) + +// Application context for creating the local database +let context = UIApplication.shared.delegate + +// The required client options +let clientOptions = ClientOptions( + api: .init(env: .dev, isSecure: true), + dbEncryptionKey: encryptionKey, + appContext: context +) // Create the client with your wallet. This will connect to the XMTP `dev` network by default. // The account is anything that conforms to the `XMTP.SigningKey` protocol. -let client = try await Client.create(account: account) +let client = try Client().create(account: account, options: clientOptions) -// Start a conversation with XMTP -let conversation = try await client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") +// Start a dm conversation +let conversation = try client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") +// Or a group conversation +let groupConversation = try client.conversations.newGroup(with: ["0x3F11b27F323b62B159D2642964fa27C46C841897"]) -// Load all messages in the conversation +// Load all messages in the conversations let messages = try await conversation.messages() + // Send a message -try await conversation.send(content: "gm") +try await conversation.send("gm") + // Listen for new messages in the conversation -for try await message in conversation.streamMessages() { - print("\(message.senderAddress): \(message.body)") +Task { + for await message in try await conversation.streamMessages() { + print("\(message.senderAddress): \(message.body)") + } } ``` -## Create a client -A client is created with `Client.create(account: SigningKey) async throws -> Client` that requires passing in an object capable of creating signatures on your behalf. The client will request a signature in two cases: +## Create a client -1. To sign the newly generated key bundle. This happens only the very first time when a key bundle is not found in storage. -2. To sign a random salt used to encrypt the key bundle in storage. This happens every time the client is started, including the very first time. +A client is created with `Client().create(account: SigningKey, options: ClientOptions): Client` that requires passing in an object capable of creating signatures on your behalf. The client will request a signature for any new installation. -> **Important** +> **Note** > The client connects to the XMTP `dev` environment by default. [Use `ClientOptions`](#configure-the-client) to change this and other parameters of the network connection. ```swift -import XMTPiOS // Create the client with a `SigningKey` from your app -let client = try await Client.create(account: account, options: .init(api: .init(env: .production))) +let options = ClientOptions(api: ClientOptions.Api(env: .production, isSecure: true), dbEncryptionKey: encryptionKey, appContext: context) +let client = try Client().create(account: account, options: options) ``` +### Create a client from saved encryptionKey -### Create a client from saved keys - -You can save your keys from the client via the `privateKeyBundle` property: +You can save your encryptionKey for the local database and build the client via address: ```swift // Create the client with a `SigningKey` from your app -let client = try await Client.create(account: account, options: .init(api: .init(env: .production))) - -// Get the key bundle -let keys = client.privateKeyBundle - -// Serialize the key bundle and store it somewhere safe -let keysData = try keys.serializedData() -``` - -Once you have those keys, you can create a new client with `Client.from`: - -```swift -let keys = try PrivateKeyBundle(serializedData: keysData) -let client = try Client.from(bundle: keys, options: .init(api: .init(env: .production))) -``` - +let options = ClientOptions(api: ClientOptions.Api(env: .production, isSecure: true), dbEncryptionKey: encryptionKey, appContext: context) +let client = try Client().build(address: account.address, options: options) +`` ### Configure the client -You can configure the client's network connection and key storage method with these optional parameters of `Client.create`: - -| Parameter | Default | Description | -| --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| env | `dev` | Connect to the specified XMTP network environment. Valid values include `.dev`, `.production`, or `.local`. For important details about working with these environments, see [XMTP `production` and `dev` network environments](#xmtp-production-and-dev-network-environments). | - -#### Configure `env` - -```swift -// Configure the client to use the `production` network -let clientOptions = ClientOptions(api: .init(env: .production)) -let client = try await Client.create(account: account, options: clientOptions) -``` - -## Configure content types +You can configure the client with these parameters of `Client.create`: -You can use custom content types by calling `Client.register`. The SDK comes with two commonly used content type codecs, `AttachmentCodec` and `RemoteAttachmentCodec`: +| Parameter | Default | Description | +| ---------- |-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| env | `DEV` | Connect to the specified XMTP network environment. Valid values include `DEV`, `.PRODUCTION`, or `LOCAL`. For important details about working with these environments, see [XMTP `production` and `dev` network environments](#xmtp-production-and-dev-network-environments). | +| appContext | `REQUIRED` | The application context used to create and access the local database. | +| dbEncryptionKey | `REQUIRED` | A 32 ByteArray used to encrypt the local database. | +| historySyncUrl | `https://message-history.dev.ephemera.network/` | The history sync url used to specify where history can be synced from other devices on the network. | +| appVersion | `undefined` | Add a client app version identifier that's included with API requests.
For example, you can use the following format: `appVersion: APP_NAME + '/' + APP_VERSION`.
Setting this value provides telemetry that shows which apps are using the XMTP client SDK. This information can help XMTP developers provide app support, especially around communicating important SDK updates, including deprecations and required upgrades. | -```swift -Client.register(AttachmentCodec()) -Client.register(RemoteAttachmentCodec()) -``` - -To learn more about using `AttachmentCodec` and `RemoteAttachmentCodec`, see [Handle different content types](#handle-different-content-types). +**Configure `env`** ## Handle conversations Most of the time, when interacting with the network, you'll want to do it through `conversations`. Conversations are between two accounts. -```swift -import XMTPiOS -// Create the client with a wallet from your app -let client = try await Client.create(account: account) -let conversations = try await client.conversations.list() -``` - -### List existing conversations - -You can get a list of all conversations that have one or more messages. +### List all dm & group conversations +If your app would like to handle groups and dms differently you can check whether a conversation is a dm or group for the type ```swift -let allConversations = try await client.conversations.list() +let conversations = try await client.conversations.list() -for conversation in allConversations { - print("Saying GM to \(conversation.peerAddress)") - try await conversation.send(content: "gm") +for conversation in conversations { + switch conversation.type { + case .group: + // Handle group + case .dm: + // Handle DM + } } ``` - +### List all groups +```swift +let groups = try await client.conversations.listGroups() +``` +### List all DMs +```swift +let dms = try await client.conversations.listDms() +``` These conversations include all conversations for a user **regardless of which app created the conversation.** This functionality provides the concept of an [interoperable inbox](https://xmtp.org/docs/concepts/interoperable-inbox), which enables a user to access all of their conversations in any app built with XMTP. ### Listen for new conversations You can also listen for new conversations being started in real-time. This will allow apps to display incoming messages from new contacts. -> **Warning** -> This stream will continue infinitely. To end the stream, break from the loop. - ```swift -for try await conversation in client.conversations.stream() { - print("New conversation started with \(conversation.peerAddress)") - - // Say hello to your new friend - try await conversation.send(content: "Hi there!") - - // Break from the loop to stop listening - break +Task { + for await conversation in try await client.conversations.stream() { + print("New conversation started with \(conversation.peerAddress)") + // Say hello to your new friend + try await conversation.send("Hi there!") + } } ``` - ### Start a new conversation You can create a new conversation with any Ethereum address on the XMTP network. ```swift -let newConversation = try await client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") +let newDm = try client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") +``` +```swift +let newGroup = try client.conversations.newGroup(with: ["0x3F11b27F323b62B159D2642964fa27C46C841897"]) ``` - ### Send messages -To be able to send a message, the recipient must have already created a client at least once and consequently advertised their key bundle on the network. Messages are addressed using account addresses. By default, the message payload supports plain strings. - -To learn about support for other content types, see [Handle different content types](#handle-different-content-types). +To be able to send a message, the recipient must have already created a client at least once. Messages are addressed using account addresses. In this example, the message payload is a plain text string. ```swift -let conversation = try await client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") -try await conversation.send(content: "Hello world") + +let conversation = try client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") +try await conversation.send("Hello world") ``` +To learn how to send other types of content, see [Handle different content types](#handle-different-types-of-content). ### List messages in a conversation You can receive the complete message history in a conversation by calling `conversation.messages()` ```swift -for conversation in client.conversations.list() { - let messagesInConversation = try await conversation.messages() -} -``` +let messages = try await conversation.messages() +`` ### List messages in a conversation with pagination It may be helpful to retrieve and process the messages in a conversation page by page. You can do this by calling `conversation.messages(limit: Int, before: Date)` which will return the specified number of messages sent before that time. + ```swift -let conversation = try await client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") let messages = try await conversation.messages(limit: 25) -let nextPage = try await conversation.messages(limit: 25, before: messages[0].sent) +let nextPage = try await conversation.messages(limit: 25, before: messages.first?.sentDate) ``` - ### Listen for new messages in a conversation You can listen for any new messages (incoming or outgoing) in a conversation by calling `conversation.streamMessages()`. A successfully received message (that makes it through the decoding and decryption without throwing) can be trusted to be authentic. Authentic means that it was sent by the owner of the `message.senderAddress` account and that it wasn't modified in transit. The `message.sent` timestamp can be trusted to have been set by the sender. -The stream returned by the `stream` methods is an asynchronous iterator and as such is usable by a for-await-of loop. Note however that it is by its nature infinite, so any looping construct used with it will not terminate, unless the termination is explicitly initiated (by breaking the loop). - -```swift -let conversation = try await client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") +The flow returned by the `stream` methods is an asynchronous data stream that sequentially emits values and completes normally or with an exception. -for try await message in conversation.streamMessages() { - if message.senderAddress == client.address { - // This message was sent from me - continue - } - - print("New message from \(message.senderAddress): \(message.body)") -} -``` - -### Decode a single message - -You can decode a single `Envelope` from XMTP using the `decode` method: ```swift -let conversation = try await client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") - -// Assume this function returns an Envelope that contains a message for the above conversation -let envelope = getEnvelopeFromXMTP() - -let decodedMessage = try conversation.decode(envelope) -``` - -### Serialize/Deserialize conversations - -You can save a conversation object locally using its `encodedContainer` property. This returns a `ConversationContainer` object which conforms to `Codable`. - -```swift -// Get a conversation -let conversation = try await client.conversations.newConversation(with: "0x3F11b27F323b62B159D2642964fa27C46C841897") - -// Get a container -let container = conversation.encodedContainer - -// Dump it to JSON -let encoder = JSONEncoder() -let data = try encoder.encode(container) -// Get it back from JSON -let decoder = JSONDecoder() -let containerAgain = try decoder.decode(ConversationContainer.self, from: data) - -// Get an actual Conversation object like we had above -let decodedConversation = containerAgain.decode(with: client) -try await decodedConversation.send(text: "hi") +Task { + for await message in try await conversation.streamMessages() { + if message.senderAddress == client.address { + // This message was sent from me + } + print("New message from \(message.senderAddress): \(message.body)") + } +} ``` - ## Request and respect user consent ![Feature status](https://img.shields.io/badge/Feature_status-Alpha-orange) @@ -279,165 +221,37 @@ The user consent feature enables your app to request and respect user consent pr To learn more, see [Request and respect user consent](https://xmtp.org/docs/build/user-consent). -## Handle different content types +## Handle different types of content -All of the send functions support `SendOptions` as an optional parameter. The `contentType` option allows specifying different types of content other than the default simple string standard content type, which is identified with content type identifier `ContentTypeText`. +All the send functions support `SendOptions` as an optional parameter. The `contentType` option allows specifying different types of content than the default simple string, which is identified with content type identifier `ContentTypeText`. To learn more about content types, see [Content types with XMTP](https://xmtp.org/docs/concepts/content-types). -Support for other content types can be added by registering additional `ContentCodec`s with the client. Every codec is associated with a content type identifier, `ContentTypeID`, which is used to signal to the client which codec should be used to process the content that is being sent or received. - -For example, see the [Codecs](https://github.com/xmtp/xmtp-ios/tree/main/Sources/XMTP/Codecs) available in `xmtp-ios`. - -### Send a remote attachment - -Use the [RemoteAttachmentCodec](https://github.com/xmtp/xmtp-ios/blob/main/Sources/XMTP/Codecs/RemoteAttachmentCodec.swift) package to enable your app to send and receive message attachments. - -Message attachments are files. More specifically, attachments are objects that have: - -- `filename` Most files have names, at least the most common file types. -- `mimeType` What kind of file is it? You can often assume this from the file extension, but it's nice to have a specific field for it. [Here's a list of common mime types.](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) -- `data` What is this file's data? Most files have data. If the file doesn't have data then it's probably not the most interesting thing to send. - -Because XMTP messages can only be up to 1MB in size, we need to store the attachment somewhere other than the XMTP network. In other words, we need to store it in a remote location. - -End-to-end encryption must apply not only to XMTP messages, but to message attachments as well. For this reason, we need to encrypt the attachment before we store it. - -#### Create an attachment object - -```swift -let attachment = Attachment( - filename: "screenshot.png", - mimeType: "image/png", - data: Data(somePNGData) -) -``` - -#### Encrypt the attachment - -Use the `RemoteAttachmentCodec.encodeEncrypted` to encrypt the attachment: - -```swift -// Encode the attachment and encrypt that encoded content -const encryptedAttachment = try RemoteAttachment.encodeEncrypted( - content: attachment, - codec: AttachmentCodec() -) -``` - -#### Upload the encrypted attachment - -Upload the encrypted attachment anywhere where it will be accessible via an HTTPS GET request. For example, you can use web3.storage: +Support for other types of content can be added by registering additional `ContentCodec`s with the Client. Every codec is associated with a content type identifier, `ContentTypeId`, which is used to signal to the Client which codec should be used to process the content that is being sent or received. ```swift -func upload(data: Data, token: String): String { - let url = URL(string: "https://api.web3.storage/upload")! - var request = URLRequest(url: url) - request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.addValue("XMTP", forHTTPHeaderField: "X-NAME") - request.httpMethod = "POST" - - let responseData = try await URLSession.shared.upload(for: request, from: data).0 - let response = try JSONDecoder().decode(Web3Storage.Response.self, from: responseData) - - return "https://\(response.cid).ipfs.w3s.link" -} - -let url = upload(data: encryptedAttachment.payload, token: YOUR_WEB3_STORAGE_TOKEN) -``` - -#### Create a remote attachment -Now that you have a `url`, you can create a `RemoteAttachment`. +// Assuming we've loaded a fictional NumberCodec that can be used to encode numbers, +// and is identified with ContentTypeNumber, we can use it as follows. +Client.register(codec: NumberCodec()) -```swift -let remoteAttachment = try RemoteAttachment( - url: url, - encryptedEncodedContent: encryptedEncodedContent -) +let options = SendOptions(contentType: .number, contentFallback: "sending you a pie") +try await aliceConversation.send(3.14, options: options) ``` +As shown in the example above, you must provide a `contentFallback` value. Use it to provide an alt text-like description of the original content. Providing a `contentFallback` value enables clients that don't support the content type to still display something meaningful. -#### Send a remote attachment - -Now that you have a remote attachment, you can send it: - -```swift -try await conversation.send( - content: remoteAttachment, - options: .init( - contentType: ContentTypeRemoteAttachment, - contentFallback: "a description of the image" - ) -) -``` +> **Caution** +> If you don't provide a `contentFallback` value, clients that don't support the content type will display an empty message. This results in a poor user experience and breaks interoperability. -Note that we’re using `contentFallback` to enable clients that don't support these content types to still display something. For cases where clients *do* support these types, they can use the content fallback as alt text for accessibility purposes. - -#### Receive a remote attachment - -Now that you can send a remote attachment, you need a way to receive a remote attachment. For example: - -```swift -let messages = try await conversation.messages() -let message = messages[0] - -guard message.encodedContent.contentType == ContentTypeRemoteAttachment else { - return -} - -const remoteAttachment: RemoteAttachment = try message.content() -``` - -#### Download, decrypt, and decode the attachment - -Now that you can receive a remote attachment, you need to download, decrypt, and decode it so your app can display it. For example: - -```swift -let attachment: Attachment = try await remoteAttachment.content() -``` - -You now have the original attachment: - -```swift -attachment.filename // => "screenshot.png" -attachment.mimeType // => "image/png", -attachment.data // => [the PNG data] -``` - -#### Display the attachment - -Display the attachment in your app as you please. For example, you can display it as an image: - -```swift -import UIKIt -import SwiftUI - -struct ContentView: View { - var body: some View { - Image(uiImage: UIImage(data: attachment.data)) - } -} -``` - -#### Handle custom content types +### Handle custom content types Beyond this, custom codecs and content types may be proposed as interoperable standards through XRCs. To learn more about the custom content type proposal process, see [XIP-5](https://github.com/xmtp/XIPs/blob/main/XIPs/xip-5-message-content-types.md). -## Compression - -Message content can be optionally compressed using the compression option. The value of the option is the name of the compression algorithm to use. Currently supported are gzip and deflate. Compression is applied to the bytes produced by the content codec. - -Content will be decompressed transparently on the receiving end. Note that Client enforces maximum content size. The default limit can be overridden through the ClientOptions. Consequently a message that would expand beyond that limit on the receiving end will fail to decode. - -```swift -try await conversation.send(text: '#'.repeat(1000), options: .init(compression: .gzip)) -``` - ## 🏗 Breaking revisions -Because `xmtp-ios` is in active development, you should expect breaking revisions that might require you to adopt the latest SDK release to enable your app to continue working as expected. +Because `xmtp-android` is in active development, you should expect breaking revisions that might require you to adopt the latest SDK release to enable your app to continue working as expected. -XMTP communicates about breaking revisions in the [XMTP Discord community](https://discord.gg/xmtp), providing as much advance notice as possible. Additionally, breaking revisions in an `xmtp-ios` release are described on the [Releases page](https://github.com/xmtp/xmtp-ios/releases). +XMTP communicates about breaking revisions in the [XMTP Discord community](https://discord.gg/xmtp), providing as much advance notice as possible. Additionally, breaking revisions in an `xmtp-android` release are described on the [Releases page](https://github.com/xmtp/xmtp-android/releases). ## Deprecation @@ -448,11 +262,11 @@ Older versions of the SDK will eventually be deprecated, which means: The following table provides the deprecation schedule. -| Announced | Effective | Minimum Version | Rationale | -| ---------- | ---------- | --------------- | ---------------------------------------------------------------------------------------------------------------- | -| There are no deprecations scheduled for `xmtp-ios` at this time. | | | | +| Announced | Effective | Minimum Version | Rationale | +|------------------------|---------------|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| No more support for V2 | March 1, 2025 | 3.0.0 | In a move towards better security with MLS and the ability to decentralize we will be shutting down V2 and moving entirely to V3 MLS. You can see the legacy branch here: https://github.com/xmtp/xmtp-android/tree/xmtp-legacy | -Bug reports, feature requests, and PRs are welcome in accordance with these [contribution guidelines](https://github.com/xmtp/xmtp-ios/blob/main/CONTRIBUTING.md). +Bug reports, feature requests, and PRs are welcome in accordance with these [contribution guidelines](https://github.com/xmtp/xmtp-android/blob/main/CONTRIBUTING.md). ## XMTP `production` and `dev` network environments @@ -461,7 +275,7 @@ XMTP provides both `production` and `dev` network environments to support the de The `production` and `dev` networks are completely separate and not interchangeable. For example, for a given blockchain account, its XMTP identity on `dev` network is completely distinct from its XMTP identity on the `production` network, as are the messages associated with these identities. In addition, XMTP identities and messages created on the `dev` network can't be accessed from or moved to the `production` network, and vice versa. -> **Important** +> **Note** > When you [create a client](#create-a-client), it connects to the XMTP `dev` environment by default. To learn how to use the `env` parameter to set your client's network environment, see [Configure the client](#configure-the-client). The `env` parameter accepts one of three valid values: `dev`, `production`, or `local`. Here are some best practices for when to use each environment: @@ -473,8 +287,3 @@ The `env` parameter accepts one of three valid values: `dev`, `production`, or ` - `local`: Use to have a client communicate with an XMTP node you are running locally. For example, an XMTP node developer can set `env` to `local` to generate client traffic to test a node running locally. The `production` network is configured to store messages indefinitely. XMTP may occasionally delete messages and keys from the `dev` network, and will provide advance notice in the [XMTP Discord community](https://discord.gg/xmtp). - -## Generate Protobufs -``` -buf generate buf.build/xmtp/proto -``` diff --git a/Sources/XMTPTestHelpers/TestHelpers.swift b/Sources/XMTPTestHelpers/TestHelpers.swift index d8cc65fc..d32a1462 100644 --- a/Sources/XMTPTestHelpers/TestHelpers.swift +++ b/Sources/XMTPTestHelpers/TestHelpers.swift @@ -1,145 +1,100 @@ -// -// TestHelpers.swift -// -// -// Created by Pat Nakajima on 12/6/22. -// - #if canImport(XCTest) -import Combine -import CryptoKit -import XCTest -@testable import XMTPiOS -import LibXMTP - -public struct TestConfig { - static let TEST_SERVER_ENABLED = _env("TEST_SERVER_ENABLED") == "true" - // TODO: change Client constructor to accept these explicitly (so we can config CI): - // static let TEST_SERVER_HOST = _env("TEST_SERVER_HOST") ?? "127.0.0.1" - // static let TEST_SERVER_PORT = Int(_env("TEST_SERVER_PORT")) ?? 5556 - // static let TEST_SERVER_IS_SECURE = _env("TEST_SERVER_IS_SECURE") == "true" - - static private func _env(_ key: String) -> String? { - ProcessInfo.processInfo.environment[key] - } - - static public func skipIfNotRunningLocalNodeTests() throws { - try XCTSkipIf(!TEST_SERVER_ENABLED, "requires local node") - } - - static public func skip(because: String) throws { - try XCTSkipIf(true, because) - } -} - -// Helper for tests gathering transcripts in a background task. -public actor TestTranscript { - public var messages: [String] = [] - public init() {} - public func add(_ message: String) { - messages.append(message) - } -} - -public struct FakeWallet: SigningKey { - public static func generate() throws -> FakeWallet { - let key = try PrivateKey.generate() - return FakeWallet(key) + import Combine + import CryptoKit + import XCTest + @testable import XMTPiOS + import LibXMTP + + public struct TestConfig { + static let TEST_SERVER_ENABLED = _env("TEST_SERVER_ENABLED") == "true" + // TODO: change Client constructor to accept these explicitly (so we can config CI): + // static let TEST_SERVER_HOST = _env("TEST_SERVER_HOST") ?? "127.0.0.1" + // static let TEST_SERVER_PORT = Int(_env("TEST_SERVER_PORT")) ?? 5556 + // static let TEST_SERVER_IS_SECURE = _env("TEST_SERVER_IS_SECURE") == "true" + + static private func _env(_ key: String) -> String? { + ProcessInfo.processInfo.environment[key] + } + + static public func skipIfNotRunningLocalNodeTests() throws { + try XCTSkipIf(!TEST_SERVER_ENABLED, "requires local node") + } + + static public func skip(because: String) throws { + try XCTSkipIf(true, because) + } } - public var address: String { - key.walletAddress + // Helper for tests gathering transcripts in a background task. + public actor TestTranscript { + public var messages: [String] = [] + public init() {} + public func add(_ message: String) { + messages.append(message) + } } - public func sign(_ data: Data) async throws -> XMTPiOS.Signature { - let signature = try await key.sign(data) - return signature - } + public struct FakeWallet: SigningKey { + public static func generate() throws -> FakeWallet { + let key = try PrivateKey.generate() + return FakeWallet(key) + } - public func sign(message: String) async throws -> XMTPiOS.Signature { - let signature = try await key.sign(message: message) - return signature - } + public var address: String { + key.walletAddress + } - public var key: PrivateKey + public func sign(_ data: Data) async throws -> XMTPiOS.Signature { + let signature = try await key.sign(data) + return signature + } - public init(_ key: PrivateKey) { - self.key = key - } -} - -public struct FakeSCWWallet: SigningKey { - public var walletAddress: String - private var internalSignature: String - - public init() throws { - // Simulate a wallet address (could be derived from a hash of some internal data) - self.walletAddress = UUID().uuidString // Using UUID for uniqueness in this fake example - self.internalSignature = Data(repeating: 0x01, count: 64).toHex // Fake internal signature - } - - public var address: String { - walletAddress - } + public func sign(message: String) async throws -> XMTPiOS.Signature { + let signature = try await key.sign(message: message) + return signature + } - public var type: WalletType { - WalletType.SCW - } - - public var chainId: Int64? { - 1 - } - - public static func generate() throws -> FakeSCWWallet { - return try FakeSCWWallet() - } - - public func signSCW(message: String) async throws -> Data { - // swiftlint:disable force_unwrapping - let digest = SHA256.hash(data: message.data(using: .utf8)!) - // swiftlint:enable force_unwrapping - return Data(digest) - } -} - -@available(iOS 15, *) -public struct Fixtures { - public var alice: PrivateKey! - public var aliceClient: Client! - - public var bob: PrivateKey! - public var bobClient: Client! - public let clientOptions: ClientOptions? = ClientOptions( - api: ClientOptions.Api(env: XMTPEnvironment.local, isSecure: false) - ) - - init() async throws { - alice = try PrivateKey.generate() - bob = try PrivateKey.generate() + public var key: PrivateKey - aliceClient = try await Client.create(account: alice, options: clientOptions) - bobClient = try await Client.create(account: bob, options: clientOptions) + public init(_ key: PrivateKey) { + self.key = key + } } - public func publishLegacyContact(client: Client) async throws { - var contactBundle = ContactBundle() - contactBundle.v1.keyBundle = try client.v1keys.toPublicKeyBundle() - - var envelope = Envelope() - envelope.contentTopic = Topic.contact(client.address).description - envelope.timestampNs = UInt64(Date().millisecondsSinceEpoch * 1_000_000) - envelope.message = try contactBundle.serializedData() - - try await client.publish(envelopes: [envelope]) + @available(iOS 15, *) + public struct Fixtures { + public var alix: PrivateKey! + public var alixClient: Client! + public var bo: PrivateKey! + public var boClient: Client! + public var caro: PrivateKey! + public var caroClient: Client! + + init() async throws { + alix = try PrivateKey.generate() + bo = try PrivateKey.generate() + caro = try PrivateKey.generate() + + let key = try Crypto.secureRandomBytes(count: 32) + let clientOptions: ClientOptions = ClientOptions( + api: ClientOptions.Api( + env: XMTPEnvironment.local, isSecure: false), + dbEncryptionKey: key + ) + + alixClient = try await Client.create( + account: alix, options: clientOptions) + boClient = try await Client.create( + account: bo, options: clientOptions) + caroClient = try await Client.create( + account: caro, options: clientOptions) + } } -} -public extension XCTestCase { - @available(iOS 15, *) - func fixtures() async -> Fixtures { - // swiftlint:disable force_try - return try! await Fixtures() - // swiftlint:enable force_try + extension XCTestCase { + @available(iOS 15, *) + public func fixtures() async throws -> Fixtures { + return try await Fixtures() + } } -} #endif diff --git a/Sources/XMTPiOS/ApiClient.swift b/Sources/XMTPiOS/ApiClient.swift deleted file mode 100644 index bc58da9d..00000000 --- a/Sources/XMTPiOS/ApiClient.swift +++ /dev/null @@ -1,178 +0,0 @@ -// -// ApiClient.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation -import LibXMTP - -public typealias PublishRequest = Xmtp_MessageApi_V1_PublishRequest -public typealias PublishResponse = Xmtp_MessageApi_V1_PublishResponse -public typealias BatchQueryRequest = Xmtp_MessageApi_V1_BatchQueryRequest -public typealias BatchQueryResponse = Xmtp_MessageApi_V1_BatchQueryResponse -public typealias Cursor = Xmtp_MessageApi_V1_Cursor -public typealias QueryRequest = Xmtp_MessageApi_V1_QueryRequest -public typealias QueryResponse = Xmtp_MessageApi_V1_QueryResponse -public typealias SubscribeRequest = Xmtp_MessageApi_V1_SubscribeRequest - -// This protocol is in place to enable extending error handling for errors -// thrown via call sites to LibXMTP.FfiConverterTypeGenericError. Adopting -// the GenericErrorDescribing protocol will catch all instances of the enum -// and generate the string descriptions. -protocol GenericErrorDescribing { - func generateApiDescription(error: GenericError) -> String -} - -extension GenericErrorDescribing { - func generateApiDescription(error: GenericError) -> String { - switch error { - case let .Client(message), - let .ClientBuilder(message), - let .Storage(message), - let .ApiError(message), - let .GroupError(message), - let .Signature(message), - let .GroupMetadata(message), - let .Generic(message), - let .GroupMutablePermissions(message), - let .SignatureRequestError(message), - let .Erc1271SignatureError(message), - let .FailedToConvertToU32(message), - let .Verifier(message): - return message - } - } -} - -struct ApiClientError: LocalizedError, GenericErrorDescribing { - var errorDescription: String? - - init(error: GenericError, description: String) { - self.errorDescription = "\(description) \(generateApiDescription(error: error))" - } -} - -protocol ApiClient: Sendable { - var environment: XMTPEnvironment { get } - init(environment: XMTPEnvironment, secure: Bool, rustClient: LibXMTP.FfiV2ApiClient, appVersion: String?) throws - func setAuthToken(_ token: String) - func batchQuery(request: BatchQueryRequest) async throws -> BatchQueryResponse - func query(topic: String, pagination: Pagination?, cursor: Xmtp_MessageApi_V1_Cursor?) async throws -> QueryResponse - func query(topic: Topic, pagination: Pagination?) async throws -> QueryResponse - func query(request: QueryRequest) async throws -> QueryResponse - func envelopes(topic: String, pagination: Pagination?) async throws -> [Envelope] - func publish(envelopes: [Envelope]) async throws - func publish(request: PublishRequest) async throws - func subscribe(request: FfiV2SubscribeRequest, callback: FfiV2SubscriptionCallback) async throws -> FfiV2Subscription -} - -func makeQueryRequest(topic: String, pagination: Pagination? = nil, cursor: Cursor? = nil) -> QueryRequest { - return QueryRequest.with { - $0.contentTopics = [topic] - if let pagination { - $0.pagingInfo = pagination.pagingInfo - } - if let endAt = pagination?.before { - $0.endTimeNs = UInt64(endAt.millisecondsSinceEpoch) * 1_000_000 - $0.pagingInfo.direction = pagination?.direction ?? .descending - } - if let startAt = pagination?.after { - $0.startTimeNs = UInt64(startAt.millisecondsSinceEpoch) * 1_000_000 - $0.pagingInfo.direction = pagination?.direction ?? .descending - } - if let cursor { - $0.pagingInfo.cursor = cursor - } - } -} - -final class GRPCApiClient: ApiClient { - let ClientVersionHeaderKey = "X-Client-Version" - let AppVersionHeaderKey = "X-App-Version" - - let environment: XMTPEnvironment - var authToken = "" - - var rustClient: LibXMTP.FfiV2ApiClient - - required init(environment: XMTPEnvironment, secure _: Bool = true, rustClient: LibXMTP.FfiV2ApiClient, appVersion: String? = nil) throws { - self.environment = environment - self.rustClient = rustClient - if let appVersion = appVersion { - rustClient.setAppVersion(version: appVersion) - } - } - - func setAuthToken(_ token: String) { - authToken = token - } - - func batchQuery(request: BatchQueryRequest) async throws -> BatchQueryResponse { - do { - return try await rustClient.batchQuery(req: request.toFFI).fromFFI - } catch let error as GenericError { - throw ApiClientError(error: error, description: "ApiClientError.batchQueryError:") - } - } - - func query(request: QueryRequest) async throws -> QueryResponse { - do { - return try await rustClient.query(request: request.toFFI).fromFFI - } catch let error as GenericError { - throw ApiClientError(error: error, description: "ApiClientError.queryError:") - } - } - - func query(topic: String, pagination: Pagination? = nil, cursor: Cursor? = nil) async throws -> QueryResponse { - return try await query(request: makeQueryRequest(topic: topic, pagination: pagination, cursor: cursor)) - } - - func query(topic: Topic, pagination: Pagination? = nil) async throws -> QueryResponse { - return try await query(request: makeQueryRequest(topic: topic.description, pagination: pagination)) - } - - func envelopes(topic: String, pagination: Pagination? = nil) async throws -> [Envelope] { - var envelopes: [Envelope] = [] - var hasNextPage = true - var cursor: Xmtp_MessageApi_V1_Cursor? - - while hasNextPage { - let response = try await query(topic: topic, pagination: pagination, cursor: cursor) - - envelopes.append(contentsOf: response.envelopes) - - cursor = response.pagingInfo.cursor - hasNextPage = !response.envelopes.isEmpty && response.pagingInfo.hasCursor - - if let limit = pagination?.limit, envelopes.count >= limit, limit <= 100 { - envelopes = Array(envelopes.prefix(limit)) - break - } - } - - return envelopes - } - - func subscribe( - request: FfiV2SubscribeRequest, - callback: FfiV2SubscriptionCallback - ) async throws -> FfiV2Subscription { - return try await rustClient.subscribe(request: request, callback: callback) - } - - func publish(request: PublishRequest) async throws { - do { - try await rustClient.publish(request: request.toFFI, authToken: authToken) - } catch let error as GenericError { - throw ApiClientError(error: error, description: "ApiClientError.publishError:") - } - } - - func publish(envelopes: [Envelope]) async throws { - return try await publish(request: PublishRequest.with { - $0.envelopes = envelopes - }) - } -} diff --git a/Sources/XMTPiOS/AuthorizedIdentity.swift b/Sources/XMTPiOS/AuthorizedIdentity.swift deleted file mode 100644 index a4ff3e31..00000000 --- a/Sources/XMTPiOS/AuthorizedIdentity.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// AuthorizedIdentity.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -struct AuthorizedIdentity { - var address: String - var authorized: PublicKey - var identity: PrivateKey - - func createAuthToken() async throws -> String { - let authData = AuthData(walletAddress: address) - let authDataBytes = try authData.serializedData() - let signature = try await identity.sign(Util.keccak256(authDataBytes)) - - var token = Token() - - token.identityKey = authorized - token.authDataBytes = authDataBytes - token.authDataSignature = signature - - return try token.serializedData().base64EncodedString() - } - - var toBundle: PrivateKeyBundle { - get throws { - var bundle = PrivateKeyBundle() - let identity = identity - let authorized = authorized - - bundle.v1.identityKey = identity - bundle.v1.identityKey.publicKey = authorized - return bundle - } - } -} - -// In an extension so we don't lose the normal struct init() -extension AuthorizedIdentity { - init(privateKeyBundleV1: PrivateKeyBundleV1) { - address = privateKeyBundleV1.identityKey.walletAddress - authorized = privateKeyBundleV1.identityKey.publicKey - identity = privateKeyBundleV1.identityKey - } -} diff --git a/Sources/XMTPiOS/Client.swift b/Sources/XMTPiOS/Client.swift index bbd9cd0a..93a2e245 100644 --- a/Sources/XMTPiOS/Client.swift +++ b/Sources/XMTPiOS/Client.swift @@ -1,10 +1,3 @@ -// -// Client.swift -// -// -// Created by Pat Nakajima on 11/22/22. -// - import Foundation import LibXMTP import web3 @@ -13,18 +6,12 @@ public typealias PreEventCallback = () async throws -> Void public enum ClientError: Error, CustomStringConvertible, LocalizedError { case creationError(String) - case noV3Client(String) - case noV2Client(String) case missingInboxId public var description: String { switch self { case .creationError(let err): return "ClientError.creationError: \(err)" - case .noV3Client(let err): - return "ClientError.noV3Client: \(err)" - case .noV2Client(let err): - return "ClientError.noV2Client: \(err)" case .missingInboxId: return "ClientError.missingInboxId" } @@ -61,38 +48,25 @@ public struct ClientOptions { public var api = Api() public var codecs: [any ContentCodec] = [] - /// `preEnableIdentityCallback` will be called immediately before an Enable Identity wallet signature is requested from the user. - public var preEnableIdentityCallback: PreEventCallback? - - /// `preCreateIdentityCallback` will be called immediately before a Create Identity wallet signature is requested from the user. - public var preCreateIdentityCallback: PreEventCallback? - /// `preAuthenticateToInboxCallback` will be called immediately before an Auth Inbox signature is requested from the user public var preAuthenticateToInboxCallback: PreEventCallback? - public var enableV3 = false - public var dbEncryptionKey: Data? + public var dbEncryptionKey: Data public var dbDirectory: String? public var historySyncUrl: String? public init( api: Api = Api(), codecs: [any ContentCodec] = [], - preEnableIdentityCallback: PreEventCallback? = nil, - preCreateIdentityCallback: PreEventCallback? = nil, preAuthenticateToInboxCallback: PreEventCallback? = nil, - enableV3: Bool = false, - encryptionKey: Data? = nil, + dbEncryptionKey: Data, dbDirectory: String? = nil, historySyncUrl: String? = nil ) { self.api = api self.codecs = codecs - self.preEnableIdentityCallback = preEnableIdentityCallback - self.preCreateIdentityCallback = preCreateIdentityCallback self.preAuthenticateToInboxCallback = preAuthenticateToInboxCallback - self.enableV3 = enableV3 - self.dbEncryptionKey = encryptionKey + self.dbEncryptionKey = dbEncryptionKey self.dbDirectory = dbDirectory if historySyncUrl == nil { switch api.env { @@ -111,35 +85,19 @@ public struct ClientOptions { } } -/// Client is the entrypoint into the XMTP SDK. -/// -/// A client is created by calling ``create(account:options:)`` with a ``SigningKey`` that can create signatures on your behalf. The client will request a signature in two cases: -/// -/// 1. To sign the newly generated key bundle. This happens only the very first time when a key bundle is not found in storage. -/// 2. To sign a random salt used to encrypt the key bundle in storage. This happens every time the client is started, including the very first time). -/// -/// > Important: The client connects to the XMTP `dev` environment by default. Use ``ClientOptions`` to change this and other parameters of the network connection. public final class Client { - /// The wallet address of the ``SigningKey`` used to create this Client. public let address: String - var privateKeyBundleV1: PrivateKeyBundleV1? = nil - var apiClient: ApiClient? = nil - public let v3Client: LibXMTP.FfiXmtpClient? + public let inboxID: String public let libXMTPVersion: String = getVersionInfo() public let dbPath: String public let installationID: String - public let inboxID: String - public var hasV2Client: Bool = true - - /// Access ``Conversations`` for this Client. - public lazy var conversations: Conversations = .init(client: self) + public let environment: XMTPEnvironment + private let ffiClient: LibXMTP.FfiXmtpClient - /// Access ``Contacts`` for this Client. - public lazy var contacts: Contacts = .init(client: self) - - /// The XMTP environment which specifies which network this Client is connected to. - public lazy var environment: XMTPEnvironment = - apiClient?.environment ?? .dev + public lazy var conversations: Conversations = .init( + client: self, ffiConversations: ffiClient.conversations()) + public lazy var preferences: PrivatePreferences = .init( + client: self, ffiClient: ffiClient) var codecRegistry = CodecRegistry() @@ -147,56 +105,25 @@ public final class Client { codecRegistry.register(codec: codec) } - /// Creates a client. - public static func create( - account: SigningKey, options: ClientOptions? = nil - ) async throws -> Client { - let options = options ?? ClientOptions() - do { - let client = try await LibXMTP.createV2Client( - host: options.api.env.url, isSecure: options.api.env.isSecure) - let apiClient = try GRPCApiClient( - environment: options.api.env, - secure: options.api.isSecure, - rustClient: client - ) - return try await create( - account: account, apiClient: apiClient, options: options) - } catch { - let detailedErrorMessage: String - if let nsError = error as NSError? { - detailedErrorMessage = nsError.description - } else { - detailedErrorMessage = error.localizedDescription - } - throw ClientError.creationError(detailedErrorMessage) - } - } - static func initializeClient( accountAddress: String, options: ClientOptions, signingKey: SigningKey?, inboxId: String ) async throws -> Client { - let (libxmtpClient, dbPath) = try await initV3Client( + let (libxmtpClient, dbPath) = try await initFFiClient( accountAddress: accountAddress, options: options, - privateKeyBundleV1: nil, signingKey: signingKey, inboxId: inboxId ) - guard let v3Client = libxmtpClient else { - throw ClientError.noV3Client("Error no V3 client initialized") - } - let client = try Client( address: accountAddress, - v3Client: v3Client, + ffiClient: libxmtpClient, dbPath: dbPath, - installationID: v3Client.installationId().toHex, - inboxID: v3Client.inboxId(), + installationID: libxmtpClient.installationId().toHex, + inboxID: libxmtpClient.inboxId(), environment: options.api.env ) @@ -208,7 +135,7 @@ public final class Client { return client } - public static func createV3(account: SigningKey, options: ClientOptions) + public static func create(account: SigningKey, options: ClientOptions) async throws -> Client { let accountAddress = account.address.lowercased() @@ -223,7 +150,7 @@ public final class Client { ) } - public static func buildV3(address: String, options: ClientOptions) + public static func build(address: String, options: ClientOptions) async throws -> Client { let accountAddress = address.lowercased() @@ -238,201 +165,94 @@ public final class Client { ) } - static func initV3Client( + static func initFFiClient( accountAddress: String, - options: ClientOptions?, - privateKeyBundleV1: PrivateKeyBundleV1?, + options: ClientOptions, signingKey: SigningKey?, inboxId: String - ) async throws -> (FfiXmtpClient?, String) { - if options?.enableV3 == true { - let address = accountAddress.lowercased() - - let mlsDbDirectory = options?.dbDirectory - var directoryURL: URL - if let mlsDbDirectory = mlsDbDirectory { - let fileManager = FileManager.default - directoryURL = URL( - fileURLWithPath: mlsDbDirectory, isDirectory: true) - // Check if the directory exists, if not, create it - if !fileManager.fileExists(atPath: directoryURL.path) { - do { - try fileManager.createDirectory( - at: directoryURL, withIntermediateDirectories: true, - attributes: nil) - } catch { - throw ClientError.creationError( - "Failed db directory \(mlsDbDirectory)") - } + ) async throws -> (FfiXmtpClient, String) { + let address = accountAddress.lowercased() + + let mlsDbDirectory = options.dbDirectory + var directoryURL: URL + if let mlsDbDirectory = mlsDbDirectory { + let fileManager = FileManager.default + directoryURL = URL( + fileURLWithPath: mlsDbDirectory, isDirectory: true) + // Check if the directory exists, if not, create it + if !fileManager.fileExists(atPath: directoryURL.path) { + do { + try fileManager.createDirectory( + at: directoryURL, withIntermediateDirectories: true, + attributes: nil) + } catch { + throw ClientError.creationError( + "Failed db directory \(mlsDbDirectory)") } - } else { - directoryURL = URL.documentsDirectory } + } else { + directoryURL = URL.documentsDirectory + } - let alias = "xmtp-\(options?.api.env.rawValue ?? "")-\(inboxId).db3" - let dbURL = directoryURL.appendingPathComponent(alias).path + let alias = "xmtp-\(options.api.env.rawValue)-\(inboxId).db3" + let dbURL = directoryURL.appendingPathComponent(alias).path - let encryptionKey = options?.dbEncryptionKey - if encryptionKey == nil { - throw ClientError.creationError( - "No encryption key passed for the database. Please store and provide a secure encryption key." - ) - } + let ffiClient = try await LibXMTP.createClient( + logger: XMTPLogger(), + host: options.api.env.url, + isSecure: options.api.env.isSecure == true, + db: dbURL, + encryptionKey: options.dbEncryptionKey, + inboxId: inboxId, + accountAddress: address, + nonce: 0, + legacySignedPrivateKeyProto: nil, + historySyncUrl: options.historySyncUrl + ) - let v3Client = try await LibXMTP.createClient( - logger: XMTPLogger(), - host: (options?.api.env ?? .local).url, - isSecure: options?.api.env.isSecure == true, - db: dbURL, - encryptionKey: encryptionKey, - inboxId: inboxId, - accountAddress: address, - nonce: 0, - legacySignedPrivateKeyProto: try privateKeyBundleV1?.toV2() - .identityKey.serializedData(), - historySyncUrl: options?.historySyncUrl - ) - - try await options?.preAuthenticateToInboxCallback?() - if let signatureRequest = v3Client.signatureRequest() { - if let signingKey = signingKey { - do { - if signingKey.type == WalletType.SCW { - guard let chainId = signingKey.chainId else { - throw ClientError.creationError( - "Chain id must be present to sign Smart Contract Wallet" - ) - } - let signedData = try await signingKey.signSCW( - message: signatureRequest.signatureText()) - try await signatureRequest.addScwSignature( - signatureBytes: signedData, - address: signingKey.address, - chainId: UInt64(chainId), - blockNumber: signingKey.blockNumber.flatMap { - $0 >= 0 ? UInt64($0) : nil - }) - - } else { - let signedData = try await signingKey.sign( - message: signatureRequest.signatureText()) - try await signatureRequest.addEcdsaSignature( - signatureBytes: signedData.rawData) + try await options.preAuthenticateToInboxCallback?() + if let signatureRequest = ffiClient.signatureRequest() { + if let signingKey = signingKey { + do { + if signingKey.type == WalletType.SCW { + guard let chainId = signingKey.chainId else { + throw ClientError.creationError( + "Chain id must be present to sign Smart Contract Wallet" + ) } - try await v3Client.registerIdentity( - signatureRequest: signatureRequest) - } catch { - throw ClientError.creationError( - "Failed to sign the message: \(error.localizedDescription)" - ) + let signedData = try await signingKey.signSCW( + message: signatureRequest.signatureText()) + try await signatureRequest.addScwSignature( + signatureBytes: signedData, + address: signingKey.address, + chainId: UInt64(chainId), + blockNumber: signingKey.blockNumber.flatMap { + $0 >= 0 ? UInt64($0) : nil + }) + + } else { + let signedData = try await signingKey.sign( + message: signatureRequest.signatureText()) + try await signatureRequest.addEcdsaSignature( + signatureBytes: signedData.rawData) } - } else { + try await ffiClient.registerIdentity( + signatureRequest: signatureRequest) + } catch { throw ClientError.creationError( - "No v3 keys found, you must pass a SigningKey in order to enable alpha MLS features" + "Failed to sign the message: \(error.localizedDescription)" ) } + } else { + throw ClientError.creationError( + "No v3 keys found, you must pass a SigningKey in order to enable alpha MLS features" + ) } - - print("LibXMTP \(getVersionInfo())") - - return (v3Client, dbURL) - } else { - return (nil, "") } - } - static func create( - account: SigningKey, apiClient: ApiClient, options: ClientOptions? = nil - ) async throws -> Client { - let privateKeyBundleV1 = try await loadOrCreateKeys( - for: account, apiClient: apiClient, options: options) - let inboxId = try await getOrCreateInboxId( - options: options ?? ClientOptions(), address: account.address) - - let (v3Client, dbPath) = try await initV3Client( - accountAddress: account.address, - options: options, - privateKeyBundleV1: privateKeyBundleV1, - signingKey: account, - inboxId: inboxId - ) - - let client = try Client( - address: account.address, privateKeyBundleV1: privateKeyBundleV1, - apiClient: apiClient, v3Client: v3Client, dbPath: dbPath, - installationID: v3Client?.installationId().toHex ?? "", - inboxID: v3Client?.inboxId() ?? inboxId) - let conversations = client.conversations - let contacts = client.contacts - try await client.ensureUserContactPublished() - - for codec in (options?.codecs ?? []) { - client.register(codec: codec) - } + print("LibXMTP \(getVersionInfo())") - return client - } - - static func loadOrCreateKeys( - for account: SigningKey, apiClient: ApiClient, - options: ClientOptions? = nil - ) async throws -> PrivateKeyBundleV1 { - if let keys = try await loadPrivateKeys( - for: account, apiClient: apiClient, options: options) - { - print("loading existing private keys.") - #if DEBUG - print("Loaded existing private keys.") - #endif - return keys - } else { - #if DEBUG - print("No existing keys found, creating new bundle.") - #endif - let keys = try await PrivateKeyBundleV1.generate( - wallet: account, options: options) - let keyBundle = PrivateKeyBundle(v1: keys) - let encryptedKeys = try await keyBundle.encrypted( - with: account, - preEnableIdentityCallback: options?.preEnableIdentityCallback) - var authorizedIdentity = AuthorizedIdentity( - privateKeyBundleV1: keys) - authorizedIdentity.address = account.address - let authToken = try await authorizedIdentity.createAuthToken() - let apiClient = apiClient - apiClient.setAuthToken(authToken) - _ = try await apiClient.publish(envelopes: [ - Envelope( - topic: .userPrivateStoreKeyBundle(account.address), - timestamp: Date(), message: encryptedKeys.serializedData()) - ]) - - return keys - } - } - - static func loadPrivateKeys( - for account: SigningKey, apiClient: ApiClient, - options: ClientOptions? = nil - ) async throws -> PrivateKeyBundleV1? { - let res = try await apiClient.query( - topic: .userPrivateStoreKeyBundle(account.address), - pagination: nil - ) - - for envelope in res.envelopes { - let encryptedBundle = try EncryptedPrivateKeyBundle( - serializedData: envelope.message) - let bundle = try await encryptedBundle.decrypted( - with: account, - preEnableIdentityCallback: options?.preEnableIdentityCallback) - if case .v1 = bundle.version { - return bundle.v1 - } - print("discarding unsupported stored key bundle") - } - - return nil + return (ffiClient, dbURL) } public static func getOrCreateInboxId( @@ -453,312 +273,27 @@ public final class Client { return inboxId } - public func canMessageV3(address: String) async throws -> Bool { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } - let canMessage = try await client.canMessage(accountAddresses: [address] - ) - return canMessage[address.lowercased()] ?? false - } - - public func canMessageV3(addresses: [String]) async throws -> [String: Bool] - { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } - - return try await client.canMessage(accountAddresses: addresses) - } - - public static func from( - bundle: PrivateKeyBundle, options: ClientOptions? = nil - ) async throws -> Client { - return try await from(v1Bundle: bundle.v1, options: options) - } - - /// Create a Client from saved v1 key bundle. - public static func from( - v1Bundle: PrivateKeyBundleV1, - options: ClientOptions? = nil, - signingKey: SigningKey? = nil - ) async throws -> Client { - let address = try v1Bundle.identityKey.publicKey - .recoverWalletSignerPublicKey().walletAddress - let options = options ?? ClientOptions() - - let inboxId = try await getOrCreateInboxId( - options: options, address: address) - - let (v3Client, dbPath) = try await initV3Client( - accountAddress: address, - options: options, - privateKeyBundleV1: v1Bundle, - signingKey: signingKey, - inboxId: inboxId - ) - - let client = try await LibXMTP.createV2Client( - host: options.api.env.url, isSecure: options.api.env.isSecure) - let apiClient = try GRPCApiClient( - environment: options.api.env, - secure: options.api.isSecure, - rustClient: client - ) - - let result = try Client( - address: address, privateKeyBundleV1: v1Bundle, - apiClient: apiClient, v3Client: v3Client, dbPath: dbPath, - installationID: v3Client?.installationId().toHex ?? "", - inboxID: v3Client?.inboxId() ?? inboxId) - let conversations = result.conversations - let contacts = result.contacts - for codec in options.codecs { - result.register(codec: codec) - } - - return result - } - - init( - address: String, privateKeyBundleV1: PrivateKeyBundleV1, - apiClient: ApiClient, v3Client: LibXMTP.FfiXmtpClient?, - dbPath: String = "", installationID: String, inboxID: String - ) throws { - self.address = address - self.privateKeyBundleV1 = privateKeyBundleV1 - self.apiClient = apiClient - self.v3Client = v3Client - self.dbPath = dbPath - self.installationID = installationID - self.inboxID = inboxID - self.hasV2Client = true - self.environment = apiClient.environment - } - init( - address: String, v3Client: LibXMTP.FfiXmtpClient, dbPath: String, + address: String, ffiClient: LibXMTP.FfiXmtpClient, dbPath: String, installationID: String, inboxID: String, environment: XMTPEnvironment ) throws { self.address = address - self.v3Client = v3Client + self.ffiClient = ffiClient self.dbPath = dbPath self.installationID = installationID self.inboxID = inboxID - self.hasV2Client = false self.environment = environment } - public var privateKeyBundle: PrivateKeyBundle { - get throws { - try PrivateKeyBundle(v1: v1keys) - } - } - - public var publicKeyBundle: SignedPublicKeyBundle { - get throws { - try v1keys.toV2().getPublicKeyBundle() - } - } - - public var v1keys: PrivateKeyBundleV1 { - get throws { - guard let keys = privateKeyBundleV1 else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - return keys - } - } - - public var keys: PrivateKeyBundleV2 { - get throws { - try v1keys.toV2() - } - } - - public func canMessage(_ peerAddress: String) async throws -> Bool { - return try await query(topic: .contact(peerAddress)).envelopes.count > 0 - } - - public static func canMessage( - _ peerAddress: String, options: ClientOptions? = nil - ) async throws -> Bool { - let options = options ?? ClientOptions() - let client = try await LibXMTP.createV2Client( - host: options.api.env.url, isSecure: options.api.env.isSecure) - let apiClient = try GRPCApiClient( - environment: options.api.env, - secure: options.api.isSecure, - rustClient: client - ) - return try await apiClient.query(topic: Topic.contact(peerAddress)) - .envelopes.count > 0 - } - - public func importConversation(from conversationData: Data) throws - -> Conversation? - { - let jsonDecoder = JSONDecoder() - - do { - let v2Export = try jsonDecoder.decode( - ConversationV2Export.self, from: conversationData) - return try importV2Conversation(export: v2Export) - } catch { - do { - let v1Export = try jsonDecoder.decode( - ConversationV1Export.self, from: conversationData) - return try importV1Conversation(export: v1Export) - } catch { - throw ConversationImportError.invalidData - } - } - } - - func importV2Conversation(export: ConversationV2Export) throws - -> Conversation - { - guard - let keyMaterial = Data(base64Encoded: Data(export.keyMaterial.utf8)) - else { - throw ConversationImportError.invalidData - } - - var consentProof: ConsentProofPayload? = nil - if let exportConsentProof = export.consentProof { - var proof = ConsentProofPayload() - proof.signature = exportConsentProof.signature - proof.timestamp = exportConsentProof.timestamp - proof.payloadVersion = - ConsentProofPayloadVersion.consentProofPayloadVersion1 - consentProof = proof - } - - return .v2( - ConversationV2( - topic: export.topic, - keyMaterial: keyMaterial, - context: InvitationV1.Context( - conversationID: export.context?.conversationId ?? "", - metadata: export.context?.metadata ?? [:] - ), - peerAddress: export.peerAddress, - client: self, - header: SealedInvitationHeaderV1(), - consentProof: consentProof - )) - } - - func importV1Conversation(export: ConversationV1Export) throws - -> Conversation - { - let formatter = ISO8601DateFormatter() - formatter.formatOptions.insert(.withFractionalSeconds) - - guard let sentAt = formatter.date(from: export.createdAt) else { - throw ConversationImportError.invalidData - } - - return .v1( - ConversationV1( - client: self, - peerAddress: export.peerAddress, - sentAt: sentAt - )) - } - - func ensureUserContactPublished() async throws { - if let contact = try await getUserContact(peerAddress: address), - case .v2 = contact.version, - try keys.getPublicKeyBundle().equals(contact.v2.keyBundle) - { - return - } - - try await publishUserContact(legacy: true) - } - - func publishUserContact(legacy: Bool = false) async throws { - var envelopes: [Envelope] = [] - - if legacy { - var contactBundle = ContactBundle() - contactBundle.v1.keyBundle = try v1keys.toPublicKeyBundle() - - var envelope = Envelope() - envelope.contentTopic = Topic.contact(address).description - envelope.timestampNs = UInt64( - Date().millisecondsSinceEpoch * 1_000_000) - envelope.message = try contactBundle.serializedData() - - envelopes.append(envelope) - } - - var contactBundle = ContactBundle() - contactBundle.v2.keyBundle = try keys.getPublicKeyBundle() - contactBundle.v2.keyBundle.identityKey.signature.ensureWalletSignature() - - var envelope = Envelope() - envelope.contentTopic = Topic.contact(address).description - envelope.timestampNs = UInt64(Date().millisecondsSinceEpoch * 1_000_000) - envelope.message = try contactBundle.serializedData() - envelopes.append(envelope) - - _ = try await publish(envelopes: envelopes) - } - - public func query(topic: Topic, pagination: Pagination? = nil) async throws - -> QueryResponse - { - guard let client = apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - return try await client.query( - topic: topic, - pagination: pagination - ) - } - - public func batchQuery(request: BatchQueryRequest) async throws - -> BatchQueryResponse - { - guard let client = apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - return try await client.batchQuery(request: request) - } - - public func publish(envelopes: [Envelope]) async throws { - guard let client = apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - let authorized = try AuthorizedIdentity( - address: address, authorized: v1keys.identityKey.publicKey, - identity: v1keys.identityKey) - let authToken = try await authorized.createAuthToken() - - client.setAuthToken(authToken) - - try await client.publish(envelopes: envelopes) - } - - public func subscribe( - topics: [String], - callback: FfiV2SubscriptionCallback - ) async throws -> FfiV2Subscription { - return try await subscribe2( - request: FfiV2SubscribeRequest(contentTopics: topics), - callback: callback) + public func canMessage(address: String) async throws -> Bool { + let canMessage = try await ffiClient.canMessage(accountAddresses: [ + address + ]) + return canMessage[address.lowercased()] ?? false } - public func subscribe2( - request: FfiV2SubscribeRequest, - callback: FfiV2SubscriptionCallback - ) async throws -> FfiV2Subscription { - guard let client = apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - return try await client.subscribe(request: request, callback: callback) + public func canMessage(addresses: [String]) async throws -> [String: Bool] { + return try await ffiClient.canMessage(accountAddresses: addresses) } public func deleteLocalDatabase() throws { @@ -773,38 +308,21 @@ public final class Client { "This function is delicate and should be used with caution. App will error if database not properly reconnected. See: reconnectLocalDatabase()" ) public func dropLocalDatabaseConnection() throws { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } - try client.releaseDbConnection() + try ffiClient.releaseDbConnection() } public func reconnectLocalDatabase() async throws { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } - try await client.dbReconnect() - } - - func getUserContact(peerAddress: String) async throws -> ContactBundle? { - let peerAddress = EthereumAddress(peerAddress).toChecksumAddress() - return try await contacts.find(peerAddress) + try await ffiClient.dbReconnect() } public func inboxIdFromAddress(address: String) async throws -> String? { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } - return try await client.findInboxId(address: address.lowercased()) + return try await ffiClient.findInboxId(address: address.lowercased()) } public func findGroup(groupId: String) throws -> Group? { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } do { return Group( - ffiGroup: try client.conversation( + ffiGroup: try ffiClient.conversation( conversationId: groupId.hexToData), client: self) } catch { return nil @@ -813,11 +331,8 @@ public final class Client { public func findConversation(conversationId: String) throws -> Conversation? { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } do { - let conversation = try client.conversation( + let conversation = try ffiClient.conversation( conversationId: conversationId.hexToData) return try conversation.toConversation(client: self) } catch { @@ -826,9 +341,6 @@ public final class Client { } public func findConversationByTopic(topic: String) throws -> Conversation? { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } do { let regexPattern = #"/xmtp/mls/1/g-(.*?)/proto"# if let regex = try? NSRegularExpression(pattern: regexPattern) { @@ -838,7 +350,7 @@ public final class Client { { let conversationId = (topic as NSString).substring( with: match.range(at: 1)) - let conversation = try client.conversation( + let conversation = try ffiClient.conversation( conversationId: conversationId.hexToData) return try conversation.toConversation(client: self) } @@ -850,54 +362,43 @@ public final class Client { } public func findDm(address: String) async throws -> Dm? { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } guard let inboxId = try await inboxIdFromAddress(address: address) else { throw ClientError.creationError("No inboxId present") } do { - let conversation = try client.dmConversation(targetInboxId: inboxId) + let conversation = try ffiClient.dmConversation( + targetInboxId: inboxId) return Dm(ffiConversation: conversation, client: self) } catch { return nil } } - public func findMessage(messageId: String) throws -> MessageV3? { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } + public func findMessage(messageId: String) throws -> Message? { do { - return MessageV3( + return Message( client: self, - ffiMessage: try client.message(messageId: messageId.hexToData)) + ffiMessage: try ffiClient.message( + messageId: messageId.hexToData)) } catch { return nil } } public func requestMessageHistorySync() async throws { - guard let client = v3Client else { - throw ClientError.noV3Client("Error no V3 client initialized") - } - try await client.requestHistorySync() + try await ffiClient.requestHistorySync() } public func revokeAllOtherInstallations(signingKey: SigningKey) async throws { - guard let client = v3Client else { - throw ClientError.noV3Client("Error: No V3 client initialized") - } - - let signatureRequest = try await client.revokeAllOtherInstallations() + let signatureRequest = try await ffiClient.revokeAllOtherInstallations() do { let signedData = try await signingKey.sign( message: signatureRequest.signatureText()) try await signatureRequest.addEcdsaSignature( signatureBytes: signedData.rawData) - try await client.applySignatureRequest( + try await ffiClient.applySignatureRequest( signatureRequest: signatureRequest) } catch { throw ClientError.creationError( @@ -907,11 +408,8 @@ public final class Client { public func inboxState(refreshFromNetwork: Bool) async throws -> InboxState { - guard let client = v3Client else { - throw ClientError.noV3Client("Error: No V3 client initialized") - } return InboxState( - ffiInboxState: try await client.inboxState( + ffiInboxState: try await ffiClient.inboxState( refreshFromNetwork: refreshFromNetwork)) } } diff --git a/Sources/XMTPiOS/Constants.swift b/Sources/XMTPiOS/Constants.swift deleted file mode 100644 index 8849898d..00000000 --- a/Sources/XMTPiOS/Constants.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Constants.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -struct Constants { - static let version = "xmtp-ios/0.0.0-development" -} diff --git a/Sources/XMTPiOS/Contacts.swift b/Sources/XMTPiOS/Contacts.swift deleted file mode 100644 index d7963054..00000000 --- a/Sources/XMTPiOS/Contacts.swift +++ /dev/null @@ -1,455 +0,0 @@ -// -// Contacts.swift -// -// -// Created by Pat Nakajima on 12/8/22. -// - -import Foundation -import LibXMTP - -public typealias PrivatePreferencesAction = Xmtp_MessageContents_PrivatePreferencesAction - -public enum ConsentState: String, Codable { - case allowed, denied, unknown -} -public enum EntryType: String, Codable { - case address, group_id, inbox_id -} - -public struct ConsentListEntry: Codable, Hashable { - static func address(_ address: String, type: ConsentState = .unknown) -> ConsentListEntry { - ConsentListEntry(value: address, entryType: .address, consentType: type) - } - - static func groupId(groupId: String, type: ConsentState = ConsentState.unknown) -> ConsentListEntry { - ConsentListEntry(value: groupId, entryType: .group_id, consentType: type) - } - - static func inboxId(_ inboxId: String, type: ConsentState = .unknown) -> ConsentListEntry { - ConsentListEntry(value: inboxId, entryType: .inbox_id, consentType: type) - } - - public var value: String - public var entryType: EntryType - public var consentType: ConsentState - - var key: String { - "\(entryType)-\(value)" - } -} - -public enum ContactError: Error { - case invalidIdentifier -} - -public actor EntriesManager { - public var map: [String: ConsentListEntry] = [:] - - func set(_ key: String, _ object: ConsentListEntry) { - map[key] = object - } - - func get(_ key: String) -> ConsentListEntry? { - map[key] - } -} - -public class ConsentList { - public let entriesManager = EntriesManager() - var lastFetched: Date? - var client: Client - - init(client: Client) { - self.client = client - } - - func load() async throws -> [ConsentListEntry] { - if (client.hasV2Client) { - let privateKey = try client.v1keys.identityKey.secp256K1.bytes - let publicKey = try client.v1keys.identityKey.publicKey.secp256K1Uncompressed.bytes - let identifier = try? LibXMTP.generatePrivatePreferencesTopicIdentifier(privateKey: privateKey) - - guard let identifier = identifier else { - throw ContactError.invalidIdentifier - } - let newDate = Date() - - let pagination = Pagination( - limit: 500, - after: lastFetched, - direction: .ascending - ) - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - let envelopes = try await apiClient.envelopes(topic: Topic.preferenceList(identifier).description, pagination: pagination) - lastFetched = newDate - - var preferences: [PrivatePreferencesAction] = [] - - for envelope in envelopes { - let payload = try LibXMTP.userPreferencesDecrypt(publicKey: publicKey, privateKey: privateKey, message: envelope.message) - - try preferences.append(PrivatePreferencesAction(serializedData: Data(payload))) - } - for preference in preferences { - for address in preference.allowAddress.walletAddresses { - _ = await allow(address: address) - } - - for address in preference.denyAddress.walletAddresses { - _ = await deny(address: address) - } - - for groupId in preference.allowGroup.groupIds { - _ = await allowGroup(groupId: groupId) - } - - for groupId in preference.denyGroup.groupIds { - _ = await denyGroup(groupId: groupId) - } - - for inboxId in preference.allowInboxID.inboxIds { - _ = await allowInboxId(inboxId: inboxId) - } - - for inboxId in preference.denyInboxID.inboxIds { - _ = await denyInboxId(inboxId: inboxId) - } - } - } - - return await Array(entriesManager.map.values) - } - - func publish(entries: [ConsentListEntry]) async throws { - if (client.v3Client != nil) { - try await setV3ConsentState(entries: entries) - } - if (client.hasV2Client) { - let privateKey = try client.v1keys.identityKey.secp256K1.bytes - let publicKey = try client.v1keys.identityKey.publicKey.secp256K1Uncompressed.bytes - let identifier = try? LibXMTP.generatePrivatePreferencesTopicIdentifier(privateKey: privateKey) - guard let identifier = identifier else { - throw ContactError.invalidIdentifier - } - var payload = PrivatePreferencesAction() - - for entry in entries { - switch entry.entryType { - case .address: - switch entry.consentType { - case .allowed: - payload.allowAddress.walletAddresses.append(entry.value) - case .denied: - payload.denyAddress.walletAddresses.append(entry.value) - case .unknown: - payload.messageType = nil - } - case .group_id: - switch entry.consentType { - case .allowed: - payload.allowGroup.groupIds.append(entry.value) - case .denied: - payload.denyGroup.groupIds.append(entry.value) - case .unknown: - payload.messageType = nil - } - case .inbox_id: - switch entry.consentType { - case .allowed: - payload.allowInboxID.inboxIds.append(entry.value) - case .denied: - payload.denyInboxID.inboxIds.append(entry.value) - case .unknown: - payload.messageType = nil - } - } - } - - let message = try LibXMTP.userPreferencesEncrypt( - publicKey: publicKey, - privateKey: privateKey, - message: payload.serializedData() - ) - - let envelope = Envelope( - topic: Topic.preferenceList(identifier), - timestamp: Date(), - message: Data(message) - ) - - try await client.publish(envelopes: [envelope]) - } - } - - func setV3ConsentState(entries: [ConsentListEntry]) async throws { - try await client.v3Client?.setConsentStates(records: entries.map(\.toFFI)) - } - - func allow(address: String) async -> ConsentListEntry { - let entry = ConsentListEntry.address(address, type: ConsentState.allowed) - await entriesManager.set(entry.key, entry) - - return entry - } - - func deny(address: String) async -> ConsentListEntry { - let entry = ConsentListEntry.address(address, type: ConsentState.denied) - await entriesManager.set(entry.key, entry) - - return entry - } - - func allowGroup(groupId: String) async -> ConsentListEntry { - let entry = ConsentListEntry.groupId(groupId: groupId, type: ConsentState.allowed) - await entriesManager.set(entry.key, entry) - - return entry - } - - func denyGroup(groupId: String) async -> ConsentListEntry { - let entry = ConsentListEntry.groupId(groupId: groupId, type: ConsentState.denied) - await entriesManager.set(entry.key, entry) - - return entry - } - - func allowInboxId(inboxId: String) async -> ConsentListEntry { - let entry = ConsentListEntry.inboxId(inboxId, type: ConsentState.allowed) - await entriesManager.set(entry.key, entry) - - return entry - } - - func denyInboxId(inboxId: String) async -> ConsentListEntry { - let entry = ConsentListEntry.inboxId(inboxId, type: ConsentState.denied) - await entriesManager.set(entry.key, entry) - - return entry - } - - func state(address: String) async throws -> ConsentState { - if let client = client.v3Client { - return try await client.getConsentState( - entityType: .address, - entity: address - ).fromFFI - } - - guard let entry = await entriesManager.get(ConsentListEntry.address(address).key) else { - return .unknown - } - - return entry.consentType - } - - func groupState(groupId: String) async throws -> ConsentState { - if let client = client.v3Client { - return try await client.getConsentState( - entityType: .conversationId, - entity: groupId - ).fromFFI - } - - guard let entry = await entriesManager.get(ConsentListEntry.groupId(groupId: groupId).key) else { - return .unknown - } - - return entry.consentType - } - - func inboxIdState(inboxId: String) async throws-> ConsentState { - if let client = client.v3Client { - return try await client.getConsentState( - entityType: .inboxId, - entity: inboxId - ).fromFFI - } - - guard let entry = await entriesManager.get(ConsentListEntry.inboxId(inboxId).key) else { - return .unknown - } - - return entry.consentType - } -} - -/// Provides access to contact bundles. -public actor Contacts { - var client: Client - - // Save all bundles here - var knownBundles: [String: ContactBundle] = [:] - - // Whether or not we have sent invite/intro to this contact - var hasIntroduced: [String: Bool] = [:] - - public var consentList: ConsentList - - init(client: Client) { - self.client = client - consentList = ConsentList(client: client) - } - - public func refreshConsentList() async throws -> ConsentList { - let entries = try await consentList.load() - try await consentList.setV3ConsentState(entries: entries) - return consentList - } - - public func isAllowed(_ address: String) async throws -> Bool { - return try await consentList.state(address: address) == .allowed - } - - public func isDenied(_ address: String) async throws -> Bool { - return try await consentList.state(address: address) == .denied - } - - public func isGroupAllowed(groupId: String) async throws -> Bool { - return try await consentList.groupState(groupId: groupId) == .allowed - } - - public func isGroupDenied(groupId: String) async throws -> Bool { - return try await consentList.groupState(groupId: groupId) == .denied - } - - public func isInboxAllowed(inboxId: String) async throws -> Bool { - return try await consentList.inboxIdState(inboxId: inboxId) == .allowed - } - - public func isInboxDenied(inboxId: String) async throws -> Bool { - return try await consentList.inboxIdState(inboxId: inboxId) == .denied - } - - public func allow(addresses: [String]) async throws { - var entries: [ConsentListEntry] = [] - - try await withThrowingTaskGroup(of: ConsentListEntry.self) { group in - for address in addresses { - group.addTask { - return await self.consentList.allow(address: address) - } - } - - for try await entry in group { - entries.append(entry) - } - } - try await consentList.publish(entries: entries) - } - - public func deny(addresses: [String]) async throws { - var entries: [ConsentListEntry] = [] - - try await withThrowingTaskGroup(of: ConsentListEntry.self) { group in - for address in addresses { - group.addTask { - return await self.consentList.deny(address: address) - } - } - - for try await entry in group { - entries.append(entry) - } - } - try await consentList.publish(entries: entries) - } - - public func allowGroups(groupIds: [String]) async throws { - var entries: [ConsentListEntry] = [] - - try await withThrowingTaskGroup(of: ConsentListEntry.self) { group in - for groupId in groupIds { - group.addTask { - return await self.consentList.allowGroup(groupId: groupId) - } - } - - for try await entry in group { - entries.append(entry) - } - } - try await consentList.publish(entries: entries) - } - - public func denyGroups(groupIds: [String]) async throws { - var entries: [ConsentListEntry] = [] - - try await withThrowingTaskGroup(of: ConsentListEntry.self) { group in - for groupId in groupIds { - group.addTask { - return await self.consentList.denyGroup(groupId: groupId) - } - } - - for try await entry in group { - entries.append(entry) - } - } - try await consentList.publish(entries: entries) - } - - public func allowInboxes(inboxIds: [String]) async throws { - var entries: [ConsentListEntry] = [] - - try await withThrowingTaskGroup(of: ConsentListEntry.self) { group in - for inboxId in inboxIds { - group.addTask { - return await self.consentList.allowInboxId(inboxId: inboxId) - } - } - - for try await entry in group { - entries.append(entry) - } - } - try await consentList.publish(entries: entries) - } - - public func denyInboxes(inboxIds: [String]) async throws { - var entries: [ConsentListEntry] = [] - - try await withThrowingTaskGroup(of: ConsentListEntry.self) { group in - for inboxId in inboxIds { - group.addTask { - return await self.consentList.denyInboxId(inboxId: inboxId) - } - } - - for try await entry in group { - entries.append(entry) - } - } - try await consentList.publish(entries: entries) - } - - func markIntroduced(_ peerAddress: String, _ isIntroduced: Bool) { - hasIntroduced[peerAddress] = isIntroduced - } - - func has(_ peerAddress: String) -> Bool { - return knownBundles[peerAddress] != nil - } - - func needsIntroduction(_ peerAddress: String) -> Bool { - return hasIntroduced[peerAddress] != true - } - - func find(_ peerAddress: String) async throws -> ContactBundle? { - if let knownBundle = knownBundles[peerAddress] { - return knownBundle - } - - let response = try await client.query(topic: .contact(peerAddress)) - - for envelope in response.envelopes { - if let contactBundle = try? ContactBundle.from(envelope: envelope) { - knownBundles[peerAddress] = contactBundle - return contactBundle - } - } - return nil - } -} diff --git a/Sources/XMTPiOS/Conversation.swift b/Sources/XMTPiOS/Conversation.swift index 0509b924..56c4ee8a 100644 --- a/Sources/XMTPiOS/Conversation.swift +++ b/Sources/XMTPiOS/Conversation.swift @@ -1,69 +1,42 @@ -// -// Conversation.swift -// -// -// Created by Pat Nakajima on 11/28/22. -// - import Foundation import LibXMTP -public enum ConversationContainer: Codable { - case v1(ConversationV1Container), v2(ConversationV2Container) - - public func decode(with client: Client) -> Conversation { - switch self { - case let .v1(container): - return .v1(container.decode(with: client)) - case let .v2(container): - return .v2(container.decode(with: client)) - } +public enum Conversation: Identifiable, Equatable, Hashable { + case group(Group) + case dm(Dm) + + public static func == (lhs: Conversation, rhs: Conversation) -> Bool { + lhs.topic == rhs.topic } -} -/// Wrapper that provides a common interface between ``ConversationV1`` and ``ConversationV2`` objects. -public enum Conversation: Sendable { - // TODO: It'd be nice to not have to expose these types as public, maybe we make this a struct with an enum prop instead of just an enum - case v1(ConversationV1), v2(ConversationV2), group(Group), dm(Dm) + public func hash(into hasher: inout Hasher) { + hasher.combine(topic) + } - public enum Version { - case v1, v2, group, dm + public enum ConversationType { + case group, dm } - + public var id: String { - get throws { - switch self { - case .v1(_): - throw ConversationError.v1NotSupported("id") - case .v2(_): - throw ConversationError.v2NotSupported("id") - case let .group(group): - return group.id - case let .dm(dm): - return dm.id - } + switch self { + case let .group(group): + return group.id + case let .dm(dm): + return dm.id } } - + public func isCreator() async throws -> Bool { switch self { - case .v1(_): - throw ConversationError.v1NotSupported("isCreator") - case .v2(_): - throw ConversationError.v2NotSupported("isCreator") case let .group(group): return try group.isCreator() case let .dm(dm): return try dm.isCreator() } } - + public func members() async throws -> [Member] { switch self { - case .v1(_): - throw ConversationError.v1NotSupported("members") - case .v2(_): - throw ConversationError.v2NotSupported("members") case let .group(group): return try await group.members case let .dm(dm): @@ -73,36 +46,24 @@ public enum Conversation: Sendable { public func consentState() async throws -> ConsentState { switch self { - case .v1(let conversationV1): - return try await conversationV1.client.contacts.consentList.state(address: peerAddress) - case .v2(let conversationV2): - return try await conversationV2.client.contacts.consentList.state(address: peerAddress) case let .group(group): return try group.consentState() case let .dm(dm): return try dm.consentState() } } - + public func updateConsentState(state: ConsentState) async throws { switch self { - case .v1(_): - throw ConversationError.v1NotSupported("updateConsentState use contact.allowAddresses instead") - case .v2(_): - throw ConversationError.v2NotSupported("updateConsentState use contact.allowAddresses instead") case let .group(group): try await group.updateConsentState(state: state) case let .dm(dm): try await dm.updateConsentState(state: state) } } - + public func sync() async throws { switch self { - case .v1(_): - throw ConversationError.v1NotSupported("sync") - case .v2(_): - throw ConversationError.v2NotSupported("sync") case let .group(group): try await group.sync() case let .dm(dm): @@ -110,51 +71,39 @@ public enum Conversation: Sendable { } } - public func processMessage(envelopeBytes: Data) async throws -> MessageV3 { + public func processMessage(messageBytes: Data) async throws -> Message { switch self { - case .v1(_): - throw ConversationError.v1NotSupported("processMessage") - case .v2(_): - throw ConversationError.v2NotSupported("processMessage") case let .group(group): - return try await group.processMessage(envelopeBytes: envelopeBytes) + return try await group.processMessage(messageBytes: messageBytes) case let .dm(dm): - return try await dm.processMessage(envelopeBytes: envelopeBytes) + return try await dm.processMessage(messageBytes: messageBytes) } } - - public func prepareMessageV3(content: T, options: SendOptions? = nil) async throws -> String { + + public func prepareMessage(content: T, options: SendOptions? = nil) + async throws -> String + { switch self { - case .v1(_): - throw ConversationError.v1NotSupported("prepareMessageV3 use prepareMessage instead") - case .v2(_): - throw ConversationError.v2NotSupported("prepareMessageV3 use prepareMessage instead") case let .group(group): - return try await group.prepareMessage(content: content, options: options) + return try await group.prepareMessage( + content: content, options: options) case let .dm(dm): - return try await dm.prepareMessage(content: content, options: options) + return try await dm.prepareMessage( + content: content, options: options) } } - public var version: Version { + public var type: ConversationType { switch self { - case .v1: - return .v1 - case .v2: - return .v2 case .group: return .group - case let .dm(dm): + case .dm: return .dm } } public var createdAt: Date { switch self { - case let .v1(conversationV1): - return conversationV1.sentAt - case let .v2(conversationV2): - return conversationV2.createdAt case let .group(group): return group.createdAt case let .dm(dm): @@ -162,12 +111,10 @@ public enum Conversation: Sendable { } } - @discardableResult public func send(content: T, options: SendOptions? = nil, fallback _: String? = nil) async throws -> String { + @discardableResult public func send( + content: T, options: SendOptions? = nil, fallback _: String? = nil + ) async throws -> String { switch self { - case let .v1(conversationV1): - return try await conversationV1.send(content: content, options: options) - case let .v2(conversationV2): - return try await conversationV2.send(content: content, options: options) case let .group(group): return try await group.send(content: content, options: options) case let .dm(dm): @@ -175,26 +122,22 @@ public enum Conversation: Sendable { } } - @discardableResult public func send(encodedContent: EncodedContent, options: SendOptions? = nil) async throws -> String { + @discardableResult public func send( + encodedContent: EncodedContent, options: SendOptions? = nil + ) async throws -> String { switch self { - case let .v1(conversationV1): - return try await conversationV1.send(encodedContent: encodedContent, options: options) - case let .v2(conversationV2): - return try await conversationV2.send(encodedContent: encodedContent, options: options) case let .group(group): - return try await group.send(content: encodedContent, options: options) + return try await group.send( + content: encodedContent, options: options) case let .dm(dm): return try await dm.send(content: encodedContent, options: options) } } - /// Send a message to the conversation - public func send(text: String, options: SendOptions? = nil) async throws -> String { + public func send(text: String, options: SendOptions? = nil) async throws + -> String + { switch self { - case let .v1(conversationV1): - return try await conversationV1.send(content: text, options: options) - case let .v2(conversationV2): - return try await conversationV2.send(content: text, options: options) case let .group(group): return try await group.send(content: text, options: options) case let .dm(dm): @@ -206,30 +149,17 @@ public enum Conversation: Sendable { return client.address } - /// The topic identifier for this conversation public var topic: String { switch self { - case let .v1(conversation): - return conversation.topic.description - case let .v2(conversation): - return conversation.topic case let .group(group): return group.topic case let .dm(dm): return dm.topic } } - - /// Returns a stream you can iterate through to receive new messages in this conversation. - /// - /// > Note: All messages in the conversation are returned by this stream. If you want to filter out messages - /// by a sender, you can check the ``Client`` address against the message's ``peerAddress``. + public func streamMessages() -> AsyncThrowingStream { switch self { - case let .v1(conversation): - return conversation.streamMessages() - case let .v2(conversation): - return conversation.streamMessages() case let .group(group): return group.streamMessages() case let .dm(dm): @@ -237,268 +167,31 @@ public enum Conversation: Sendable { } } - public func streamDecryptedMessages() -> AsyncThrowingStream { - switch self { - case let .v1(conversation): - return conversation.streamDecryptedMessages() - case let .v2(conversation): - return conversation.streamDecryptedMessages() - case let .group(group): - return group.streamDecryptedMessages() - case let .dm(dm): - return dm.streamDecryptedMessages() - } - } - - /// List messages in the conversation - public func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] { - switch self { - case let .v1(conversationV1): - return try await conversationV1.messages(limit: limit, before: before, after: after, direction: direction) - case let .v2(conversationV2): - return try await conversationV2.messages(limit: limit, before: before, after: after, direction: direction) - case let .group(group): - return try await group.messages(before: before, after: after, limit: limit, direction: direction) - case let .dm(dm): - return try await dm.messages(before: before, after: after, limit: limit, direction: direction) - } - } - - public func decryptedMessages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecryptedMessage] { + public func messages( + limit: Int? = nil, before: Date? = nil, after: Date? = nil, + direction: SortDirection? = .descending, + deliveryStatus: MessageDeliveryStatus = .all + ) async throws -> [DecodedMessage] { switch self { - case let .v1(conversationV1): - return try await conversationV1.decryptedMessages(limit: limit, before: before, after: after, direction: direction) - case let .v2(conversationV2): - return try await conversationV2.decryptedMessages(limit: limit, before: before, after: after, direction: direction) case let .group(group): - return try await group.decryptedMessages(before: before, after: after, limit: limit, direction: direction) + return try await group.messages( + before: before, after: after, limit: limit, + direction: direction, deliveryStatus: deliveryStatus + ) case let .dm(dm): - return try await dm.decryptedMessages(before: before, after: after, limit: limit, direction: direction) - } - } - - public var consentProof: ConsentProofPayload? { - switch self { - case .v1(_): - return nil - case let .v2(conversationV2): - return conversationV2.consentProof - case .group(_): - return nil - case let .dm(dm): - return nil + return try await dm.messages( + before: before, after: after, limit: limit, + direction: direction, deliveryStatus: deliveryStatus + ) } } var client: Client { switch self { - case let .v1(conversationV1): - return conversationV1.client - case let .v2(conversationV2): - return conversationV2.client case let .group(group): return group.client case let .dm(dm): return dm.client } } - - // ------- V1 V2 to be deprecated ------ - - public func encodedContainer() throws -> ConversationContainer { - switch self { - case let .v1(conversationV1): - return .v1(conversationV1.encodedContainer) - case let .v2(conversationV2): - return .v2(conversationV2.encodedContainer) - case .group(_): - throw ConversationError.v3NotSupported("encodedContainer") - case .dm(_): - throw ConversationError.v3NotSupported("encodedContainer") - } - } - - /// The wallet address of the other person in this conversation. - public var peerAddress: String { - get throws { - switch self { - case let .v1(conversationV1): - return conversationV1.peerAddress - case let .v2(conversationV2): - return conversationV2.peerAddress - case .group(_): - throw ConversationError.v3NotSupported("peerAddress use members inboxId instead") - case .dm(_): - throw ConversationError.v3NotSupported("peerAddress use members inboxId instead") - } - } - } - - public var peerAddresses: [String] { - get throws { - switch self { - case let .v1(conversationV1): - return [conversationV1.peerAddress] - case let .v2(conversationV2): - return [conversationV2.peerAddress] - case .group(_): - throw ConversationError.v3NotSupported("peerAddresses use members inboxIds instead") - case .dm(_): - throw ConversationError.v3NotSupported("peerAddresses use members inboxIds instead") - } - } - } - - public var keyMaterial: Data? { - switch self { - case let .v1(conversationV1): - return nil - case let .v2(conversationV2): - return conversationV2.keyMaterial - case .group(_): - return nil - case .dm(_): - return nil - } - } - - /// An optional string that can specify a different context for a conversation with another account address. - /// - /// > Note: ``conversationID`` is only available for ``ConversationV2`` conversations. - public var conversationID: String? { - switch self { - case .v1: - return nil - case let .v2(conversation): - return conversation.context.conversationID - case .group(_): - return nil - case .dm(_): - return nil - } - } - - /// Exports the serializable topic data required for later import. - /// See Conversations.importTopicData() - public func toTopicData() throws -> Xmtp_KeystoreApi_V1_TopicMap.TopicData { - try Xmtp_KeystoreApi_V1_TopicMap.TopicData.with { - $0.createdNs = UInt64(createdAt.timeIntervalSince1970 * 1000) * 1_000_000 - $0.peerAddress = try peerAddress - if case let .v2(cv2) = self { - $0.invitation = Xmtp_MessageContents_InvitationV1.with { - $0.topic = cv2.topic - $0.context = cv2.context - $0.aes256GcmHkdfSha256 = Xmtp_MessageContents_InvitationV1.Aes256gcmHkdfsha256.with { - $0.keyMaterial = cv2.keyMaterial - } - } - } - } - } - - public func decode(_ envelope: Envelope) throws -> DecodedMessage { - switch self { - case let .v1(conversationV1): - return try conversationV1.decode(envelope: envelope) - case let .v2(conversationV2): - return try conversationV2.decode(envelope: envelope) - case .group(_): - throw ConversationError.v3NotSupported("decode use decodeV3 instead") - case .dm(_): - throw ConversationError.v3NotSupported("decode use decodeV3 instead") - } - } - - public func decrypt(_ envelope: Envelope) throws -> DecryptedMessage { - switch self { - case let .v1(conversationV1): - return try conversationV1.decrypt(envelope: envelope) - case let .v2(conversationV2): - return try conversationV2.decrypt(envelope: envelope) - case .group(_): - throw ConversationError.v3NotSupported("decrypt use decryptV3 instead") - case .dm(_): - throw ConversationError.v3NotSupported("decrypt use decryptV3 instead") - } - } - - public func encode(codec: Codec, content: T) async throws -> Data where Codec.T == T { - switch self { - case let .v1: - throw RemoteAttachmentError.v1NotSupported - case let .v2(conversationV2): - return try await conversationV2.encode(codec: codec, content: content) - case .group(_): - throw ConversationError.v3NotSupported("encode") - case .dm(_): - throw ConversationError.v3NotSupported("encode") - } - } - - public func prepareMessage(encodedContent: EncodedContent, options: SendOptions? = nil) async throws -> PreparedMessage { - switch self { - case let .v1(conversationV1): - return try await conversationV1.prepareMessage(encodedContent: encodedContent, options: options) - case let .v2(conversationV2): - return try await conversationV2.prepareMessage(encodedContent: encodedContent, options: options) - case .group(_): - throw ConversationError.v3NotSupported("prepareMessage use prepareMessageV3 instead") - case .dm(_): - throw ConversationError.v3NotSupported("prepareMessage use prepareMessageV3 instead") - } - } - - public func prepareMessage(content: T, options: SendOptions? = nil) async throws -> PreparedMessage { - switch self { - case let .v1(conversationV1): - return try await conversationV1.prepareMessage(content: content, options: options ?? .init()) - case let .v2(conversationV2): - return try await conversationV2.prepareMessage(content: content, options: options ?? .init()) - case .group(_): - throw ConversationError.v3NotSupported("prepareMessage use prepareMessageV3 instead") - case .dm(_): - throw ConversationError.v3NotSupported("prepareMessage use prepareMessageV3 instead") - } - } - - // This is a convenience for invoking the underlying `client.publish(prepared.envelopes)` - // If a caller has a `Client` handy, they may opt to do that directly instead. - @discardableResult public func send(prepared: PreparedMessage) async throws -> String { - switch self { - case let .v1(conversationV1): - return try await conversationV1.send(prepared: prepared) - case let .v2(conversationV2): - return try await conversationV2.send(prepared: prepared) - case .group(_): - throw ConversationError.v3NotSupported("send(prepareMessage) use send(content) instead") - case .dm(_): - throw ConversationError.v3NotSupported("send(prepareMessage) use send(content) instead") - } - } - - - public func streamEphemeral() throws -> AsyncThrowingStream? { - switch self { - case let .v1(conversation): - return conversation.streamEphemeral() - case let .v2(conversation): - return conversation.streamEphemeral() - case .group(_): - throw ConversationError.v3NotSupported("streamEphemeral") - case .dm(_): - throw ConversationError.v3NotSupported("streamEphemeral") - } - } - - -} - -extension Conversation: Hashable, Equatable { - public static func == (lhs: Conversation, rhs: Conversation) -> Bool { - lhs.topic == rhs.topic - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(topic) - } } diff --git a/Sources/XMTPiOS/ConversationExport.swift b/Sources/XMTPiOS/ConversationExport.swift deleted file mode 100644 index 2e8fba13..00000000 --- a/Sources/XMTPiOS/ConversationExport.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// ConversationExport.swift -// -// -// Created by Pat Nakajima on 2/1/23. -// - -enum ConversationImportError: Error { - case invalidData -} - -struct ConversationV1Export: Codable { - var version: String - var peerAddress: String - var createdAt: String -} - -// TODO: Make these match ConversationContainer -struct ConversationV2Export: Codable { - var version: String - var topic: String - var keyMaterial: String - var peerAddress: String - var createdAt: String - var context: ConversationV2ContextExport? - var consentProof: ConsentProofPayloadExport? -} - -struct ConversationV2ContextExport: Codable { - var conversationId: String - var metadata: [String: String] -} - -struct ConsentProofPayloadExport: Codable { - var signature: String - var timestamp: UInt64 -} diff --git a/Sources/XMTPiOS/ConversationV1.swift b/Sources/XMTPiOS/ConversationV1.swift deleted file mode 100644 index aa6960c0..00000000 --- a/Sources/XMTPiOS/ConversationV1.swift +++ /dev/null @@ -1,291 +0,0 @@ -// -// ConversationV1.swift -// -// -// Created by Pat Nakajima on 11/28/22. -// - -import CryptoKit -import Foundation - -// Save the non-client parts for a v1 conversation -public struct ConversationV1Container: Codable { - var peerAddress: String - var sentAt: Date - - func decode(with client: Client) -> ConversationV1 { - ConversationV1(client: client, peerAddress: peerAddress, sentAt: sentAt) - } -} - -/// Handles legacy message conversations. -public struct ConversationV1 { - public var client: Client - public var peerAddress: String - public var sentAt: Date - - public init(client: Client, peerAddress: String, sentAt: Date) { - self.client = client - self.peerAddress = peerAddress - self.sentAt = sentAt - } - - public var encodedContainer: ConversationV1Container { - ConversationV1Container(peerAddress: peerAddress, sentAt: sentAt) - } - - var topic: Topic { - Topic.directMessageV1(client.address, peerAddress) - } - - func prepareMessage(encodedContent: EncodedContent, options: SendOptions?) async throws -> PreparedMessage { - guard let contact = try await client.contacts.find(peerAddress) else { - throw ContactBundleError.notFound - } - - let recipient = try contact.toPublicKeyBundle() - - if !recipient.identityKey.hasSignature { - fatalError("no signature for id key") - } - - let date = sentAt - - let message = try MessageV1.encode( - sender: client.v1keys, - recipient: recipient, - message: try encodedContent.serializedData(), - timestamp: date - ) - - let isEphemeral: Bool - if let options, options.ephemeral { - isEphemeral = true - } else { - isEphemeral = false - } - let msg = try Message(v1: message).serializedData() - let messageEnvelope = Envelope( - topic: isEphemeral ? ephemeralTopic : topic.description, - timestamp: date, - message: msg - ) - var envelopes = [messageEnvelope] - if (await client.contacts.needsIntroduction(peerAddress)) && !isEphemeral { - envelopes.append(contentsOf: [ - Envelope( - topic: .userIntro(peerAddress), - timestamp: date, - message: msg - ), - Envelope( - topic: .userIntro(client.address), - timestamp: date, - message: msg - ), - ]) - - await client.contacts.markIntroduced(peerAddress, true) - } - - return PreparedMessage(envelopes: envelopes, encodedContent: encodedContent) - } - - func prepareMessage(content: T, options: SendOptions?) async throws -> PreparedMessage { - let codec = client.codecRegistry.find(for: options?.contentType) - - func encode(codec: Codec, content: Any) throws -> EncodedContent { - if let content = content as? Codec.T { - return try codec.encode(content: content, client: client) - } else { - throw CodecError.invalidContent - } - } - - let content = content as T - var encoded = try encode(codec: codec, content: content) - - func fallback(codec: Codec, content: Any) throws -> String? { - if let content = content as? Codec.T { - return try codec.fallback(content: content) - } else { - throw CodecError.invalidContent - } - } - - if let fallback = try fallback(codec: codec, content: content) { - encoded.fallback = fallback - } - - if let compression = options?.compression { - encoded = try encoded.compress(compression) - } - - return try await prepareMessage(encodedContent: encoded, options: options) - } - - @discardableResult func send(content: String, options: SendOptions? = nil) async throws -> String { - return try await send(content: content, options: options, sentAt: nil) - } - - @discardableResult internal func send(content: String, options: SendOptions? = nil, sentAt _: Date? = nil) async throws -> String { - let preparedMessage = try await prepareMessage(content: content, options: options) - return try await send(prepared: preparedMessage) - } - - @discardableResult func send(encodedContent: EncodedContent, options: SendOptions?) async throws -> String { - let preparedMessage = try await prepareMessage(encodedContent: encodedContent, options: options) - return try await send(prepared: preparedMessage) - } - - @discardableResult func send(prepared: PreparedMessage) async throws -> String { - if (client.v3Client != nil) { - do { - let dm = try await client.conversations.findOrCreateDm(with: peerAddress) - if let encodedContent = prepared.encodedContent { - try await dm.send(encodedContent: encodedContent) - } - } catch { - print("ConversationV1 send \(error)") - } - } - try await client.publish(envelopes: prepared.envelopes) - if((try await client.contacts.consentList.state(address: peerAddress)) == .unknown) { - try await client.contacts.allow(addresses: [peerAddress]) - } - return prepared.messageID - } - - func send(content: T, options: SendOptions? = nil) async throws -> String { - let preparedMessage = try await prepareMessage(content: content, options: options) - return try await send(prepared: preparedMessage) - } - - public func streamMessages() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - let streamCallback = V2SubscriptionCallback { envelope in - do { - let decodedMessage = try decode(envelope: envelope) - continuation.yield(decodedMessage) - } catch {} - } - - let stream = try await client.subscribe(topics: [topic.description], callback: streamCallback) - - continuation.onTermination = { @Sendable reason in - Task { - try await stream.end() - } - } - } - } - } - - public func streamDecryptedMessages() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - let streamCallback = V2SubscriptionCallback { envelope in - do { - let decrypted = try decrypt(envelope: envelope) - continuation.yield(decrypted) - } catch {} - } - - let stream = try await client.subscribe(topics: [topic.description], callback: streamCallback) - - continuation.onTermination = { @Sendable reason in - Task { - try await stream.end() - } - } - } - } - } - - var ephemeralTopic: String { - topic.description.replacingOccurrences(of: "/xmtp/0/dm-", with: "/xmtp/0/dmE-") - } - - public func streamEphemeral() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - let streamCallback = V2SubscriptionCallback { envelope in - continuation.yield(envelope) - } - - let stream = try await client.subscribe(topics: [ephemeralTopic], callback: streamCallback) - - continuation.onTermination = { @Sendable reason in - Task { - try await stream.end() - } - } - } - } - } - - func decryptedMessages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecryptedMessage] { - let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - let envelopes = try await apiClient.envelopes( - topic: Topic.directMessageV1(client.address, peerAddress).description, - pagination: pagination - ) - - return try envelopes.map { try decrypt(envelope: $0) } - } - - func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] { - let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - let envelopes = try await apiClient.envelopes( - topic: Topic.directMessageV1(client.address, peerAddress).description, - pagination: pagination - ) - - return envelopes.compactMap { envelope in - do { - return try decode(envelope: envelope) - } catch { - print("ERROR DECODING CONVO V1 MESSAGE: \(error)") - return nil - } - } - } - - func decrypt(envelope: Envelope) throws -> DecryptedMessage { - let message = try Message(serializedData: envelope.message) - let decrypted = try message.v1.decrypt(with: client.v1keys) - - let encodedMessage = try EncodedContent(serializedData: decrypted) - let header = try message.v1.header - - return DecryptedMessage(id: generateID(from: envelope), encodedContent: encodedMessage, senderAddress: header.sender.walletAddress, sentAt: message.v1.sentAt) - } - - public func decode(envelope: Envelope) throws -> DecodedMessage { - let decryptedMessage = try decrypt(envelope: envelope) - - var decoded = DecodedMessage( - client: client, - topic: envelope.contentTopic, - encodedContent: decryptedMessage.encodedContent, - senderAddress: decryptedMessage.senderAddress, - sent: decryptedMessage.sentAt - ) - - decoded.id = generateID(from: envelope) - - return decoded - } - - private func generateID(from envelope: Envelope) -> String { - Data(SHA256.hash(data: envelope.message)).toHex - } -} diff --git a/Sources/XMTPiOS/ConversationV2.swift b/Sources/XMTPiOS/ConversationV2.swift deleted file mode 100644 index d119e1ea..00000000 --- a/Sources/XMTPiOS/ConversationV2.swift +++ /dev/null @@ -1,302 +0,0 @@ -// -// ConversationV2.swift -// -// -// Created by Pat Nakajima on 11/26/22. -// - -import CryptoKit -import Foundation - -// Save the non-client parts for a v2 conversation -public struct ConversationV2Container: Codable { - var topic: String - var keyMaterial: Data - var conversationID: String? - var metadata: [String: String] = [:] - var peerAddress: String - var createdAtNs: UInt64? - var header: SealedInvitationHeaderV1 - var consentProof: ConsentProofPayload? - - public func decode(with client: Client) -> ConversationV2 { - let context = InvitationV1.Context(conversationID: conversationID ?? "", metadata: metadata) - return ConversationV2(topic: topic, keyMaterial: keyMaterial, context: context, peerAddress: peerAddress, client: client, createdAtNs: createdAtNs, header: header, consentProof: consentProof) - } -} - -/// Handles V2 Message conversations. -public struct ConversationV2 { - public var topic: String - public var keyMaterial: Data // MUST be kept secret - public var context: InvitationV1.Context - public var peerAddress: String - public var client: Client - public var consentProof: ConsentProofPayload? - var createdAtNs: UInt64? - private var header: SealedInvitationHeaderV1 - - static func create(client: Client, invitation: InvitationV1, header: SealedInvitationHeaderV1) throws -> ConversationV2 { - let myKeys = try client.keys.getPublicKeyBundle() - - let peer = try myKeys.walletAddress == (try header.sender.walletAddress) ? header.recipient : header.sender - let peerAddress = try peer.walletAddress - - let keyMaterial = Data(invitation.aes256GcmHkdfSha256.keyMaterial) - - return ConversationV2( - topic: invitation.topic, - keyMaterial: keyMaterial, - context: invitation.context, - peerAddress: peerAddress, - client: client, - createdAtNs: header.createdNs, - header: header, - consentProof: invitation.hasConsentProof ? invitation.consentProof : nil - ) - } - - public init(topic: String, keyMaterial: Data, context: InvitationV1.Context, peerAddress: String, client: Client, createdAtNs: UInt64? = nil, consentProof: ConsentProofPayload? = nil) { - self.topic = topic - self.keyMaterial = keyMaterial - self.context = context - self.peerAddress = peerAddress - self.client = client - self.createdAtNs = createdAtNs - self.consentProof = consentProof - header = SealedInvitationHeaderV1() - } - - public init(topic: String, keyMaterial: Data, context: InvitationV1.Context, peerAddress: String, client: Client, createdAtNs: UInt64? = nil, header: SealedInvitationHeaderV1, consentProof: ConsentProofPayload? = nil) { - self.topic = topic - self.keyMaterial = keyMaterial - self.context = context - self.peerAddress = peerAddress - self.client = client - self.createdAtNs = createdAtNs - self.header = header - self.consentProof = consentProof - } - - public var encodedContainer: ConversationV2Container { - ConversationV2Container(topic: topic, keyMaterial: keyMaterial, conversationID: context.conversationID, metadata: context.metadata, peerAddress: peerAddress, createdAtNs: createdAtNs, header: header, consentProof: consentProof) - } - - func prepareMessage(encodedContent: EncodedContent, options: SendOptions?) async throws -> PreparedMessage { - let codec = client.codecRegistry.find(for: options?.contentType) - - let message = try await MessageV2.encode( - client: client, - content: encodedContent, - topic: topic, - keyMaterial: keyMaterial, - codec: codec - ) - - let topic = options?.ephemeral == true ? ephemeralTopic : topic - - let envelope = Envelope(topic: topic, timestamp: Date(), message: try Message(v2: message).serializedData()) - return PreparedMessage(envelopes: [envelope], encodedContent: encodedContent) - } - - func prepareMessage(content: T, options: SendOptions?) async throws -> PreparedMessage { - let codec = client.codecRegistry.find(for: options?.contentType) - - func encode(codec: Codec, content: Any) throws -> EncodedContent { - if let content = content as? Codec.T { - return try codec.encode(content: content, client: client) - } else { - throw CodecError.invalidContent - } - } - - var encoded = try encode(codec: codec, content: content) - - func fallback(codec: Codec, content: Any) throws -> String? { - if let content = content as? Codec.T { - return try codec.fallback(content: content) - } else { - throw CodecError.invalidContent - } - } - - if let fallback = try fallback(codec: codec, content: content) { - encoded.fallback = fallback - } - - if let compression = options?.compression { - encoded = try encoded.compress(compression) - } - - return try await prepareMessage(encodedContent: encoded, options: options) - } - - func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] { - let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - let envelopes = try await apiClient.envelopes(topic: topic.description, pagination: pagination) - - return envelopes.compactMap { envelope in - do { - return try decode(envelope: envelope) - } catch { - print("Error decoding envelope \(error)") - return nil - } - } - } - - func decryptedMessages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecryptedMessage] { - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - let pagination = Pagination(limit: limit, before: before, after: after, direction: direction) - let envelopes = try await apiClient.envelopes(topic: topic.description, pagination: pagination) - - return try envelopes.map { envelope in - try decrypt(envelope: envelope) - } - } - - func decrypt(envelope: Envelope) throws -> DecryptedMessage { - let message = try Message(serializedData: envelope.message) - return try MessageV2.decrypt(generateID(from: envelope), topic, message.v2, keyMaterial: keyMaterial, client: client) - } - - var ephemeralTopic: String { - topic.replacingOccurrences(of: "/xmtp/0/m", with: "/xmtp/0/mE") - } - - public func streamEphemeral() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - let streamCallback = V2SubscriptionCallback { envelope in - continuation.yield(envelope) - } - - let stream = try await client.subscribe(topics: [ephemeralTopic], callback: streamCallback) - - continuation.onTermination = { @Sendable reason in - Task { - try await stream.end() - } - } - } - } - } - - public func streamMessages() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - let streamCallback = V2SubscriptionCallback { envelope in - do { - let decodedMessage = try decode(envelope: envelope) - continuation.yield(decodedMessage) - } catch {} - } - - let stream = try await client.subscribe(topics: [topic.description], callback: streamCallback) - - continuation.onTermination = { @Sendable reason in - Task { - try await stream.end() - } - } - } - } - } - - public func streamDecryptedMessages() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - let streamCallback = V2SubscriptionCallback { envelope in - do { - let decrypted = try decrypt(envelope: envelope) - continuation.yield(decrypted) - } catch {} - } - - let stream = try await client.subscribe(topics: [topic.description], callback: streamCallback) - - continuation.onTermination = { @Sendable reason in - Task { - try await stream.end() - } - } - } - } - } - - public var createdAt: Date { - Date(timeIntervalSince1970: Double((createdAtNs ?? header.createdNs) / 1_000_000) / 1000) - } - - public func decode(envelope: Envelope) throws -> DecodedMessage { - let message = try Message(serializedData: envelope.message) - - return try MessageV2.decode(generateID(from: envelope), topic, message.v2, keyMaterial: keyMaterial, client: client) - } - - @discardableResult func send(content: T, options: SendOptions? = nil) async throws -> String { - let preparedMessage = try await prepareMessage(content: content, options: options) - return try await send(prepared: preparedMessage) - } - - @discardableResult func send(content: String, options: SendOptions? = nil, sentAt _: Date) async throws -> String { - let preparedMessage = try await prepareMessage(content: content, options: options) - return try await send(prepared: preparedMessage) - } - - @discardableResult func send(encodedContent: EncodedContent, options: SendOptions? = nil) async throws -> String { - let preparedMessage = try await prepareMessage(encodedContent: encodedContent, options: options) - return try await send(prepared: preparedMessage) - } - - @discardableResult func send(prepared: PreparedMessage) async throws -> String { - if (client.v3Client != nil) { - do { - let dm = try await client.conversations.findOrCreateDm(with: peerAddress) - if let encodedContent = prepared.encodedContent { - try await dm.send(encodedContent: encodedContent) - } - } catch { - print("ConversationV2 send \(error)") - } - } - try await client.publish(envelopes: prepared.envelopes) - if((try await client.contacts.consentList.state(address: peerAddress)) == .unknown) { - try await client.contacts.allow(addresses: [peerAddress]) - } - return prepared.messageID - } - - public func encode(codec: Codec, content: T) async throws -> Data where Codec.T == T { - let content = try codec.encode(content: content, client: client) - - let message = try await MessageV2.encode( - client: client, - content: content, - topic: topic, - keyMaterial: keyMaterial, - codec: codec - ) - - let envelope = Envelope( - topic: topic, - timestamp: Date(), - message: try Message(v2: message).serializedData() - ) - - return try envelope.serializedData() - } - - @discardableResult func send(content: String, options: SendOptions? = nil) async throws -> String { - return try await send(content: content, options: options, sentAt: Date()) - } - - private func generateID(from envelope: Envelope) -> String { - Data(SHA256.hash(data: envelope.message)).toHex - } -} diff --git a/Sources/XMTPiOS/Conversations.swift b/Sources/XMTPiOS/Conversations.swift index d2b0fbe1..bf0ffe65 100644 --- a/Sources/XMTPiOS/Conversations.swift +++ b/Sources/XMTPiOS/Conversations.swift @@ -2,45 +2,12 @@ import Foundation import LibXMTP public enum ConversationError: Error, CustomStringConvertible, LocalizedError { - case recipientNotOnNetwork, recipientIsSender - case v1NotSupported(String) - case v2NotSupported(String) - case v3NotSupported(String) - - public var description: String { - switch self { - case .recipientIsSender: - return - "ConversationError.recipientIsSender: Recipient cannot be sender" - case .recipientNotOnNetwork: - return - "ConversationError.recipientNotOnNetwork: Recipient is not on network" - case .v1NotSupported(let str): - return - "ConversationError.v1NotSupported: V1 does not support: \(str)" - case .v2NotSupported(let str): - return - "ConversationError.v2NotSupported: V2 does not support: \(str)" - case .v3NotSupported(let str): - return - "ConversationError.v3NotSupported: V3 does not support: \(str)" - } - } - - public var errorDescription: String? { - return description - } -} - -public enum GroupError: Error, CustomStringConvertible, LocalizedError { - case alphaMLSNotEnabled, memberCannotBeSelf + case memberCannotBeSelf case memberNotRegistered([String]) case groupsRequireMessagePassed, notSupportedByGroups, streamingFailure public var description: String { switch self { - case .alphaMLSNotEnabled: - return "GroupError.alphaMLSNotEnabled" case .memberCannotBeSelf: return "GroupError.memberCannotBeSelf you cannot add yourself to a group" @@ -83,38 +50,6 @@ final class ConversationStreamCallback: FfiConversationCallback { } } -final class V2SubscriptionCallback: FfiV2SubscriptionCallback { - func onError(error: LibXMTP.GenericError) { - print("Error V2SubscriptionCallback \(error)") - } - - let callback: (Envelope) -> Void - - init(callback: @escaping (Envelope) -> Void) { - self.callback = callback - } - - func onMessage(message: LibXMTP.FfiEnvelope) { - self.callback(message.fromFFI) - } -} - -class StreamManager { - var stream: FfiV2Subscription? - - func updateStream(with request: FfiV2SubscribeRequest) async throws { - try await stream?.update(req: request) - } - - func endStream() async throws { - try await stream?.end() - } - - func setStream(_ newStream: FfiV2Subscription?) { - self.stream = newStream - } -} - actor FfiStreamActor { private var ffiStream: FfiStreamCloser? @@ -130,39 +65,23 @@ actor FfiStreamActor { /// Handles listing and creating Conversations. public actor Conversations { var client: Client - var conversationsByTopic: [String: Conversation] = [:] + var ffiConversations: FfiConversations - init(client: Client) { + init(client: Client, ffiConversations: FfiConversations) { self.client = client + self.ffiConversations = ffiConversations } public func sync() async throws { - guard let v3Client = client.v3Client else { - return - } - try await v3Client.conversations().sync() + try await ffiConversations.sync() } - - public func syncAllGroups() async throws -> UInt32 { - guard let v3Client = client.v3Client else { - return 0 - } - return try await v3Client.conversations().syncAllConversations() - } - public func syncAllConversations() async throws -> UInt32 { - guard let v3Client = client.v3Client else { - return 0 - } - return try await v3Client.conversations().syncAllConversations() + return try await ffiConversations.syncAllConversations() } - public func groups( + public func listGroups( createdAfter: Date? = nil, createdBefore: Date? = nil, limit: Int? = nil ) async throws -> [Group] { - guard let v3Client = client.v3Client else { - return [] - } var options = FfiListConversationsOptions( createdAfterNs: nil, createdBeforeNs: nil, limit: nil, consentState: nil) @@ -176,16 +95,14 @@ public actor Conversations { if let limit { options.limit = Int64(limit) } - return try await v3Client.conversations().listGroups(opts: options).map - { $0.groupFromFFI(client: client) } + return try await ffiConversations.listGroups(opts: options).map { + $0.groupFromFFI(client: client) + } } - public func dms( + public func listDms( createdAfter: Date? = nil, createdBefore: Date? = nil, limit: Int? = nil ) async throws -> [Dm] { - guard let v3Client = client.v3Client else { - return [] - } var options = FfiListConversationsOptions( createdAfterNs: nil, createdBeforeNs: nil, limit: nil, consentState: nil) @@ -199,19 +116,16 @@ public actor Conversations { if let limit { options.limit = Int64(limit) } - return try await v3Client.conversations().listDms(opts: options).map { + return try await ffiConversations.listDms(opts: options).map { $0.dmFromFFI(client: client) } } - public func listConversations( + public func list( createdAfter: Date? = nil, createdBefore: Date? = nil, limit: Int? = nil, order: ConversationOrder = .createdAt, consentState: ConsentState? = nil ) async throws -> [Conversation] { - guard let v3Client = client.v3Client else { - return [] - } var options = FfiListConversationsOptions( createdAfterNs: nil, createdBeforeNs: nil, limit: nil, consentState: consentState?.toFFI) @@ -225,11 +139,11 @@ public actor Conversations { if let limit { options.limit = Int64(limit) } - let ffiConversations = try await v3Client.conversations().list( + let conversations = try await ffiConversations.list( opts: options) let sortedConversations = try sortConversations( - ffiConversations, order: order) + conversations, order: order) return try sortedConversations.map { try $0.toConversation(client: client) @@ -265,84 +179,13 @@ public actor Conversations { } } - public func streamGroups() async throws -> AsyncThrowingStream - { - AsyncThrowingStream { continuation in - let ffiStreamActor = FfiStreamActor() - let task = Task { - let groupCallback = ConversationStreamCallback { group in - guard !Task.isCancelled else { - continuation.finish() - return - } - continuation.yield(group.groupFromFFI(client: self.client)) - } - guard - let stream = await self.client.v3Client?.conversations() - .streamGroups(callback: groupCallback) - else { - continuation.finish(throwing: GroupError.streamingFailure) - return - } - await ffiStreamActor.setFfiStream(stream) - continuation.onTermination = { @Sendable reason in - Task { - await ffiStreamActor.endStream() - } - } - } - - continuation.onTermination = { @Sendable reason in - task.cancel() - Task { - await ffiStreamActor.endStream() - } - } - } - } - - private func streamGroupConversations() -> AsyncThrowingStream< - Conversation, Error - > { - AsyncThrowingStream { continuation in - let ffiStreamActor = FfiStreamActor() - let task = Task { - let stream = await self.client.v3Client?.conversations() - .streamGroups( - callback: ConversationStreamCallback { group in - guard !Task.isCancelled else { - continuation.finish() - return - } - continuation.yield( - Conversation.group( - group.groupFromFFI(client: self.client))) - } - ) - await ffiStreamActor.setFfiStream(stream) - continuation.onTermination = { @Sendable reason in - Task { - await ffiStreamActor.endStream() - } - } - } - - continuation.onTermination = { @Sendable reason in - task.cancel() - Task { - await ffiStreamActor.endStream() - } - } - } - } - - public func streamConversations() -> AsyncThrowingStream< + public func stream() -> AsyncThrowingStream< Conversation, Error > { AsyncThrowingStream { continuation in let ffiStreamActor = FfiStreamActor() let task = Task { - let stream = await self.client.v3Client?.conversations().stream( + let stream = await ffiConversations.stream( callback: ConversationStreamCallback { conversation in guard !Task.isCancelled else { continuation.finish() @@ -388,29 +231,22 @@ public actor Conversations { } public func findOrCreateDm(with peerAddress: String) async throws -> Dm { - guard let v3Client = client.v3Client else { - throw GroupError.alphaMLSNotEnabled - } if peerAddress.lowercased() == client.address.lowercased() { - throw ConversationError.recipientIsSender + throw ConversationError.memberCannotBeSelf } - let canMessage = try await self.client.canMessageV3( + let canMessage = try await self.client.canMessage( address: peerAddress) if !canMessage { - throw ConversationError.recipientNotOnNetwork + throw ConversationError.memberNotRegistered([peerAddress]) } - - try await client.contacts.allow(addresses: [peerAddress]) - if let existingDm = try await client.findDm(address: peerAddress) { return existingDm } - let newDm = try await v3Client.conversations() + let newDm = + try await ffiConversations .createDm(accountAddress: peerAddress.lowercased()) .dmFromFFI(client: client) - - try await client.contacts.allow(addresses: [peerAddress]) return newDm } @@ -464,38 +300,21 @@ public actor Conversations { pinnedFrameUrl: String = "", permissionPolicySet: FfiPermissionPolicySet? = nil ) async throws -> Group { - guard let v3Client = client.v3Client else { - throw GroupError.alphaMLSNotEnabled - } if addresses.first(where: { $0.lowercased() == client.address.lowercased() }) != nil { - throw GroupError.memberCannotBeSelf - } - let erroredAddresses = try await withThrowingTaskGroup( - of: (String?).self - ) { group in - for address in addresses { - group.addTask { - if try await self.client.canMessageV3(address: address) { - return nil - } else { - return address - } - } - } - var results: [String] = [] - for try await result in group { - if let result { - results.append(result) - } - } - return results + throw ConversationError.memberCannotBeSelf } - if !erroredAddresses.isEmpty { - throw GroupError.memberNotRegistered(erroredAddresses) + let addressMap = try await self.client.canMessage(addresses: addresses) + let unregisteredAddresses = addressMap + .filter { !$0.value } + .map { $0.key } + + if !unregisteredAddresses.isEmpty { + throw ConversationError.memberNotRegistered(unregisteredAddresses) } - let group = try await v3Client.conversations().createGroup( + + let group = try await ffiConversations.createGroup( accountAddresses: addresses, opts: FfiCreateGroupOptions( permissions: permissions, @@ -506,56 +325,17 @@ public actor Conversations { customPermissionPolicySet: permissionPolicySet ) ).groupFromFFI(client: client) - try await client.contacts.allowGroups(groupIds: [group.id]) return group } - public func streamAllConversationMessages() -> AsyncThrowingStream< + public func streamAllMessages() -> AsyncThrowingStream< DecodedMessage, Error > { AsyncThrowingStream { continuation in let ffiStreamActor = FfiStreamActor() let task = Task { - let stream = await self.client.v3Client?.conversations() - .streamAllMessages( - messageCallback: MessageCallback(client: self.client) { - message in - guard !Task.isCancelled else { - continuation.finish() - Task { - await ffiStreamActor.endStream() // End the stream upon cancellation - } - return - } - do { - continuation.yield( - try MessageV3( - client: self.client, ffiMessage: message - ).decode()) - } catch { - print("Error onMessage \(error)") - } - } - ) - await ffiStreamActor.setFfiStream(stream) - } - - continuation.onTermination = { _ in - task.cancel() - Task { - await ffiStreamActor.endStream() - } - } - } - } - - public func streamAllDecryptedConversationMessages() -> AsyncThrowingStream< - DecryptedMessage, Error - > { - AsyncThrowingStream { continuation in - let ffiStreamActor = FfiStreamActor() - let task = Task { - let stream = await self.client.v3Client?.conversations() + let stream = + await ffiConversations .streamAllMessages( messageCallback: MessageCallback(client: self.client) { message in @@ -568,46 +348,7 @@ public actor Conversations { } do { continuation.yield( - try MessageV3( - client: self.client, ffiMessage: message - ).decrypt()) - } catch { - print("Error onMessage \(error)") - } - } - ) - await ffiStreamActor.setFfiStream(stream) - } - - continuation.onTermination = { _ in - task.cancel() - Task { - await ffiStreamActor.endStream() - } - } - } - } - - public func streamAllGroupMessages() -> AsyncThrowingStream< - DecodedMessage, Error - > { - AsyncThrowingStream { continuation in - let ffiStreamActor = FfiStreamActor() - let task = Task { - let stream = await self.client.v3Client?.conversations() - .streamAllGroupMessages( - messageCallback: MessageCallback(client: self.client) { - message in - guard !Task.isCancelled else { - continuation.finish() - Task { - await ffiStreamActor.endStream() // End the stream upon cancellation - } - return - } - do { - continuation.yield( - try MessageV3( + try Message( client: self.client, ffiMessage: message ).decode()) } catch { @@ -627,856 +368,19 @@ public actor Conversations { } } - public func streamAllMessages(includeGroups: Bool = false) - -> AsyncThrowingStream - { - AsyncThrowingStream { continuation in - @Sendable func forwardStreamToMerged( - stream: AsyncThrowingStream - ) async { - do { - var iterator = stream.makeAsyncIterator() - while let element = try await iterator.next() { - guard !Task.isCancelled else { - continuation.finish() - return - } - continuation.yield(element) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - let task = Task { - await forwardStreamToMerged(stream: streamAllV2Messages()) - } - - let groupTask = - includeGroups - ? Task { - await forwardStreamToMerged( - stream: streamAllGroupMessages()) - } : nil - - continuation.onTermination = { _ in - task.cancel() - groupTask?.cancel() - } - } - } - - public func streamAllGroupDecryptedMessages() -> AsyncThrowingStream< - DecryptedMessage, Error - > { - AsyncThrowingStream { continuation in - let ffiStreamActor = FfiStreamActor() - let task = Task { - let stream = await self.client.v3Client?.conversations() - .streamAllGroupMessages( - messageCallback: MessageCallback(client: self.client) { - message in - guard !Task.isCancelled else { - continuation.finish() - Task { - await ffiStreamActor.endStream() // End the stream upon cancellation - } - return - } - do { - continuation.yield( - try MessageV3( - client: self.client, ffiMessage: message - ).decrypt()) - } catch { - print("Error onMessage \(error)") - } - } - ) - await ffiStreamActor.setFfiStream(stream) - } - - continuation.onTermination = { _ in - task.cancel() - Task { - await ffiStreamActor.endStream() - } - } - } - } - - public func streamAllDecryptedMessages(includeGroups: Bool = false) - -> AsyncThrowingStream - { - AsyncThrowingStream { continuation in - @Sendable func forwardStreamToMerged( - stream: AsyncThrowingStream - ) async { - do { - var iterator = stream.makeAsyncIterator() - while let element = try await iterator.next() { - guard !Task.isCancelled else { - continuation.finish() - return - } - continuation.yield(element) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - let task = Task { - await forwardStreamToMerged( - stream: streamAllV2DecryptedMessages()) - } - - let groupTask = - includeGroups - ? Task { - await forwardStreamToMerged( - stream: streamAllGroupDecryptedMessages()) - } : nil - - continuation.onTermination = { _ in - task.cancel() - groupTask?.cancel() - } - } - } - - private func findExistingConversation( - with peerAddress: String, conversationID: String? - ) throws -> Conversation? { - return try conversationsByTopic.first(where: { - try $0.value.peerAddress == peerAddress - && (($0.value.conversationID ?? "") == (conversationID ?? "")) - })?.value - } - - public func fromWelcome(envelopeBytes: Data) async throws -> Group? { - guard let v3Client = client.v3Client else { - return nil - } - let group = try await v3Client.conversations() - .processStreamedWelcomeMessage(envelopeBytes: envelopeBytes) - return Group(ffiGroup: group, client: client) - } - - public func conversationFromWelcome(envelopeBytes: Data) async throws + public func fromWelcome(envelopeBytes: Data) async throws -> Conversation? { - guard let v3Client = client.v3Client else { - return nil - } - let conversation = try await v3Client.conversations() + let conversation = + try await ffiConversations .processStreamedWelcomeMessage(envelopeBytes: envelopeBytes) return try conversation.toConversation(client: client) } public func newConversation( - with peerAddress: String, context: InvitationV1.Context? = nil, - consentProofPayload: ConsentProofPayload? = nil + with peerAddress: String ) async throws -> Conversation { - if peerAddress.lowercased() == client.address.lowercased() { - throw ConversationError.recipientIsSender - } - print("\(client.address) starting conversation with \(peerAddress)") - if let existing = try findExistingConversation( - with: peerAddress, conversationID: context?.conversationID) - { - return existing - } - guard let contact = try await client.contacts.find(peerAddress) else { - throw ConversationError.recipientNotOnNetwork - } - _ = try await list() // cache old conversations and check again - if let existing = try findExistingConversation( - with: peerAddress, conversationID: context?.conversationID) - { - return existing - } - // We don't have an existing conversation, make a v2 one - let recipient = try contact.toSignedPublicKeyBundle() - let invitation = try InvitationV1.createDeterministic( - sender: client.keys, - recipient: recipient, - context: context, - consentProofPayload: consentProofPayload - ) - let sealedInvitation = try await sendInvitation( - recipient: recipient, invitation: invitation, created: Date()) - let conversationV2 = try ConversationV2.create( - client: client, invitation: invitation, - header: sealedInvitation.v1.header) - try await client.contacts.allow(addresses: [peerAddress]) - let conversation: Conversation = .v2(conversationV2) - Task { - await self.addConversation(conversation) - } - if client.v3Client != nil { - do { - try await client.conversations.findOrCreateDm(with: peerAddress) - } catch { - print("newConversation \(error)") - } - } - return conversation - } - - public func stream() async throws -> AsyncThrowingStream< - Conversation, Error - > { - AsyncThrowingStream { continuation in - Task { - var streamedConversationTopics: Set = [] - let subscriptionCallback = V2SubscriptionCallback { envelope in - Task { - if envelope.contentTopic - == Topic.userIntro(self.client.address).description - { - let conversationV1 = try self.fromIntro( - envelope: envelope) - if !streamedConversationTopics.contains( - conversationV1.topic.description) - { - streamedConversationTopics.insert( - conversationV1.topic.description) - continuation.yield(conversationV1) - } - } - if envelope.contentTopic - == Topic.userInvite(self.client.address).description - { - let conversationV2 = try self.fromInvite( - envelope: envelope) - if !streamedConversationTopics.contains( - conversationV2.topic) - { - streamedConversationTopics.insert( - conversationV2.topic) - continuation.yield(conversationV2) - } - } - } - } - - let stream = try await client.subscribe( - topics: [ - Topic.userIntro(client.address).description, - Topic.userInvite(client.address).description, - ], callback: subscriptionCallback) - - continuation.onTermination = { @Sendable reason in - Task { - try await stream.end() - } - } - } - } - } - - public func streamAll() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - @Sendable func forwardStreamToMerged( - stream: AsyncThrowingStream - ) async { - do { - var iterator = stream.makeAsyncIterator() - while let element = try await iterator.next() { - continuation.yield(element) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - Task { - await forwardStreamToMerged(stream: try stream()) - } - Task { - await forwardStreamToMerged(stream: streamGroupConversations()) - } - } - } - - private func validateConsentSignature( - signature: String, clientAddress: String, peerAddress: String, - timestamp: UInt64 - ) -> Bool { - // timestamp should be in the past - if timestamp > UInt64(Date().timeIntervalSince1970 * 1000) { - return false - } - let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60) - let thirtyDaysAgoTimestamp = UInt64( - thirtyDaysAgo.timeIntervalSince1970 * 1000) - if timestamp < thirtyDaysAgoTimestamp { - return false - } - let message = Signature.consentProofText( - peerAddress: peerAddress, timestamp: timestamp) - guard let signatureData = Data(hex: signature) else { - print("Invalid signature format") - return false - } - do { - let ethMessage = try Signature.ethHash(message) - let recoveredKey = try KeyUtilx.recoverPublicKey( - message: ethMessage, signature: signatureData) - let address = KeyUtilx.generateAddress(from: recoveredKey) - .toChecksumAddress() - return clientAddress == address - } catch { - return false - } - } - - private func handleConsentProof( - consentProof: ConsentProofPayload, peerAddress: String - ) async throws { - let signature = consentProof.signature - if signature == "" { - return - } - if !validateConsentSignature( - signature: signature, clientAddress: client.address, - peerAddress: peerAddress, timestamp: consentProof.timestamp) - { - return - } - let contacts = client.contacts - _ = try await contacts.refreshConsentList() - if try await - (contacts.consentList.state(address: peerAddress) == .unknown) - { - try await contacts.allow(addresses: [peerAddress]) - } - } - - public func list(includeGroups: Bool = false) async throws -> [Conversation] - { - if includeGroups { - try await sync() - let groups = try await groups() - for group in groups { - await self.addConversation(.group(group)) - } - } - var newConversations: [Conversation] = [] - let mostRecent = await self.getMostRecentConversation() - let pagination = Pagination(after: mostRecent?.createdAt) - do { - let seenPeers = try await listIntroductionPeers( - pagination: pagination) - for (peerAddress, sentAt) in seenPeers { - let newConversation = Conversation.v1( - ConversationV1( - client: client, peerAddress: peerAddress, sentAt: sentAt - )) - newConversations.append(newConversation) - } - } catch { - print("Error loading introduction peers: \(error)") - } - for sealedInvitation in try await listInvitations( - pagination: pagination) - { - do { - let newConversation = Conversation.v2( - try makeConversation(from: sealedInvitation)) - newConversations.append(newConversation) - if let consentProof = newConversation.consentProof, - consentProof.signature != "" - { - try await self.handleConsentProof( - consentProof: consentProof, - peerAddress: newConversation.peerAddress) - } - } catch { - print("Error loading invitations: \(error)") - } - } - for conversation in newConversations { - if try conversation.peerAddress != client.address - && Topic.isValidTopic(topic: conversation.topic) - { - await self.addConversation(conversation) - } - } - return await self.getSortedConversations() - } - - private func addConversation(_ conversation: Conversation) async { - conversationsByTopic[conversation.topic] = conversation - } - - private func getMostRecentConversation() async -> Conversation? { - return conversationsByTopic.values.max { a, b in - a.createdAt < b.createdAt - } - } - - private func getSortedConversations() async -> [Conversation] { - return conversationsByTopic.values.sorted { a, b in - a.createdAt < b.createdAt - } - } - - public func getHmacKeys( - request: Xmtp_KeystoreApi_V1_GetConversationHmacKeysRequest? = nil - ) -> Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse { - let thirtyDayPeriodsSinceEpoch = - Int(Date().timeIntervalSince1970) / (60 * 60 * 24 * 30) - var hmacKeysResponse = - Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse() - var topics = conversationsByTopic - if let requestTopics = request?.topics, !requestTopics.isEmpty { - topics = topics.filter { requestTopics.contains($0.key) } - } - for (topic, conversation) in topics { - guard let keyMaterial = conversation.keyMaterial else { continue } - var hmacKeys = - Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse.HmacKeys() - for period - in (thirtyDayPeriodsSinceEpoch - 1)...(thirtyDayPeriodsSinceEpoch - + 1) - { - let info = "\(period)-\(client.address)" - do { - let hmacKey = try Crypto.deriveKey( - secret: keyMaterial, nonce: Data(), - info: Data(info.utf8)) - var hmacKeyData = - Xmtp_KeystoreApi_V1_GetConversationHmacKeysResponse - .HmacKeyData() - hmacKeyData.hmacKey = hmacKey - hmacKeyData.thirtyDayPeriodsSinceEpoch = Int32(period) - hmacKeys.values.append(hmacKeyData) - } catch { - print( - "Error calculating HMAC key for topic \(topic): \(error)" - ) - } - } - hmacKeysResponse.hmacKeys[topic] = hmacKeys - } - return hmacKeysResponse - } - - // ------- V1 V2 to be deprecated ------ - - /// Import a previously seen conversation. - /// See Conversation.toTopicData() - public func importTopicData(data: Xmtp_KeystoreApi_V1_TopicMap.TopicData) - throws -> Conversation - { - if !client.hasV2Client { - throw ConversationError.v3NotSupported( - "importTopicData only supported with V2 clients") - } - let conversation: Conversation - if !data.hasInvitation { - let sentAt = Date( - timeIntervalSince1970: TimeInterval( - data.createdNs / 1_000_000_000)) - conversation = .v1( - ConversationV1( - client: client, peerAddress: data.peerAddress, - sentAt: sentAt)) - } else { - conversation = .v2( - ConversationV2( - topic: data.invitation.topic, - keyMaterial: data.invitation.aes256GcmHkdfSha256 - .keyMaterial, - context: data.invitation.context, - peerAddress: data.peerAddress, - client: client, - createdAtNs: data.createdNs - )) - } - Task { - await self.addConversation(conversation) - } - return conversation - } - - public func listBatchMessages(topics: [String: Pagination?]) async throws - -> [DecodedMessage] - { - if !client.hasV2Client { - throw ConversationError.v3NotSupported( - "listBatchMessages only supported with V2 clients. Use listConversations order lastMessage" - ) - } - let requests = topics.map { topic, page in - makeQueryRequest(topic: topic, pagination: page) - } - /// The maximum number of requests permitted in a single batch call. - let maxQueryRequestsPerBatch = 50 - let batches = requests.chunks(maxQueryRequestsPerBatch) - .map { requests in BatchQueryRequest.with { $0.requests = requests } - } - var messages: [DecodedMessage] = [] - // TODO: consider using a task group here for parallel batch calls - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - for batch in batches { - messages += try await apiClient.batchQuery(request: batch) - .responses.flatMap { res in - res.envelopes.compactMap { envelope in - let conversation = conversationsByTopic[ - envelope.contentTopic] - if conversation == nil { - print( - "discarding message, unknown conversation \(envelope)" - ) - return nil - } - do { - return try conversation?.decode(envelope) - } catch { - print( - "discarding message, unable to decode \(envelope)" - ) - return nil - } - } - } - } - return messages - } - - public func listBatchDecryptedMessages(topics: [String: Pagination?]) - async throws -> [DecryptedMessage] - { - if !client.hasV2Client { - throw ConversationError.v3NotSupported( - "listBatchMessages only supported with V2 clients. Use listConversations order lastMessage" - ) - } - let requests = topics.map { topic, page in - makeQueryRequest(topic: topic, pagination: page) - } - /// The maximum number of requests permitted in a single batch call. - let maxQueryRequestsPerBatch = 50 - let batches = requests.chunks(maxQueryRequestsPerBatch) - .map { requests in BatchQueryRequest.with { $0.requests = requests } - } - var messages: [DecryptedMessage] = [] - // TODO: consider using a task group here for parallel batch calls - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - for batch in batches { - messages += try await apiClient.batchQuery(request: batch) - .responses.flatMap { res in - res.envelopes.compactMap { envelope in - let conversation = conversationsByTopic[ - envelope.contentTopic] - if conversation == nil { - print( - "discarding message, unknown conversation \(envelope)" - ) - return nil - } - do { - return try conversation?.decrypt(envelope) - } catch { - print( - "discarding message, unable to decode \(envelope)" - ) - return nil - } - } - } - } - return messages - } - - private func makeConversation(from sealedInvitation: SealedInvitation) - throws -> ConversationV2 - { - let unsealed = try sealedInvitation.v1.getInvitation( - viewer: client.keys) - return try ConversationV2.create( - client: client, invitation: unsealed, - header: sealedInvitation.v1.header) - } - - func streamAllV2Messages() -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - if !client.hasV2Client { - continuation.finish( - throwing: ConversationError.v3NotSupported( - "Only supported with V2 clients. Use streamAllConversationMessages instead." - )) - return - } - let streamManager = StreamManager() - - Task { - var topics: [String] = [ - Topic.userInvite(client.address).description, - Topic.userIntro(client.address).description, - ] - - for conversation in try await list() { - topics.append(conversation.topic) - } - - var subscriptionRequest = FfiV2SubscribeRequest( - contentTopics: topics) - - let subscriptionCallback = V2SubscriptionCallback { envelope in - Task { - do { - if let conversation = self.conversationsByTopic[ - envelope.contentTopic] - { - let decoded = try conversation.decode(envelope) - continuation.yield(decoded) - } else if envelope.contentTopic.hasPrefix( - "/xmtp/0/invite-") - { - let conversation = try self.fromInvite( - envelope: envelope) - await self.addConversation(conversation) - topics.append(conversation.topic) - subscriptionRequest = FfiV2SubscribeRequest( - contentTopics: topics) - try await streamManager.updateStream( - with: subscriptionRequest) - } else if envelope.contentTopic.hasPrefix( - "/xmtp/0/intro-") - { - let conversation = try self.fromIntro( - envelope: envelope) - await self.addConversation(conversation) - let decoded = try conversation.decode(envelope) - continuation.yield(decoded) - topics.append(conversation.topic) - subscriptionRequest = FfiV2SubscribeRequest( - contentTopics: topics) - try await streamManager.updateStream( - with: subscriptionRequest) - } else { - print("huh \(envelope)") - } - } catch { - continuation.finish(throwing: error) - } - } - } - let newStream = try await client.subscribe2( - request: subscriptionRequest, callback: subscriptionCallback - ) - streamManager.setStream(newStream) - - continuation.onTermination = { @Sendable reason in - Task { - try await streamManager.endStream() - } - } - } - } - } - - func streamAllV2DecryptedMessages() -> AsyncThrowingStream< - DecryptedMessage, Error - > { - AsyncThrowingStream { continuation in - let streamManager = StreamManager() - if !client.hasV2Client { - continuation.finish( - throwing: ConversationError.v3NotSupported( - "Only supported with V2 clients. Use streamAllDecryptedConversationMessages instead." - )) - return - } - Task { - var topics: [String] = [ - Topic.userInvite(client.address).description, - Topic.userIntro(client.address).description, - ] - - for conversation in try await list() { - topics.append(conversation.topic) - } - - var subscriptionRequest = FfiV2SubscribeRequest( - contentTopics: topics) - - let subscriptionCallback = V2SubscriptionCallback { envelope in - Task { - do { - if let conversation = self.conversationsByTopic[ - envelope.contentTopic] - { - let decrypted = try conversation.decrypt( - envelope) - continuation.yield(decrypted) - } else if envelope.contentTopic.hasPrefix( - "/xmtp/0/invite-") - { - let conversation = try self.fromInvite( - envelope: envelope) - await self.addConversation(conversation) - topics.append(conversation.topic) - subscriptionRequest = FfiV2SubscribeRequest( - contentTopics: topics) - try await streamManager.updateStream( - with: subscriptionRequest) - } else if envelope.contentTopic.hasPrefix( - "/xmtp/0/intro-") - { - let conversation = try self.fromIntro( - envelope: envelope) - await self.addConversation(conversation) - let decrypted = try conversation.decrypt( - envelope) - continuation.yield(decrypted) - topics.append(conversation.topic) - subscriptionRequest = FfiV2SubscribeRequest( - contentTopics: topics) - try await streamManager.updateStream( - with: subscriptionRequest) - } else { - print("huh \(envelope)") - } - } catch { - continuation.finish(throwing: error) - } - } - } - let newStream = try await client.subscribe2( - request: subscriptionRequest, callback: subscriptionCallback - ) - streamManager.setStream(newStream) - - continuation.onTermination = { @Sendable reason in - Task { - try await streamManager.endStream() - } - } - } - } - } - - public func fromInvite(envelope: Envelope) throws -> Conversation { - if !client.hasV2Client { - throw ConversationError.v3NotSupported( - "fromIntro only supported with V2 clients use fromWelcome instead" - ) - } - let sealedInvitation = try SealedInvitation( - serializedData: envelope.message) - let unsealed = try sealedInvitation.v1.getInvitation( - viewer: client.keys) - return try .v2( - ConversationV2.create( - client: client, invitation: unsealed, - header: sealedInvitation.v1.header)) - } - - public func fromIntro(envelope: Envelope) throws -> Conversation { - if !client.hasV2Client { - throw ConversationError.v3NotSupported( - "fromIntro only supported with V2 clients use fromWelcome instead" - ) - } - let messageV1 = try MessageV1.fromBytes(envelope.message) - let senderAddress = try messageV1.header.sender.walletAddress - let recipientAddress = try messageV1.header.recipient.walletAddress - let peerAddress = - client.address == senderAddress ? recipientAddress : senderAddress - let conversationV1 = ConversationV1( - client: client, peerAddress: peerAddress, sentAt: messageV1.sentAt) - return .v1(conversationV1) - } - - private func listIntroductionPeers(pagination: Pagination?) async throws - -> [String: Date] - { - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - let envelopes = try await apiClient.query( - topic: .userIntro(client.address), - pagination: pagination - ).envelopes - let messages = envelopes.compactMap { envelope in - do { - let message = try MessageV1.fromBytes(envelope.message) - // Attempt to decrypt, just to make sure we can - _ = try message.decrypt(with: client.v1keys) - return message - } catch { - return nil - } - } - var seenPeers: [String: Date] = [:] - for message in messages { - guard let recipientAddress = message.recipientAddress, - let senderAddress = message.senderAddress - else { - continue - } - let sentAt = message.sentAt - let peerAddress = - recipientAddress == client.address - ? senderAddress : recipientAddress - guard let existing = seenPeers[peerAddress] else { - seenPeers[peerAddress] = sentAt - continue - } - if existing > sentAt { - seenPeers[peerAddress] = sentAt - } - } - return seenPeers - } - - private func listInvitations(pagination: Pagination?) async throws - -> [SealedInvitation] - { - guard let apiClient = client.apiClient else { - throw ClientError.noV2Client("Error no V2 client initialized") - } - var envelopes = try await apiClient.envelopes( - topic: Topic.userInvite(client.address).description, - pagination: pagination - ) - return envelopes.compactMap { envelope in - // swiftlint:disable no_optional_try - try? SealedInvitation(serializedData: envelope.message) - // swiftlint:enable no_optional_try - } - } - - func sendInvitation( - recipient: SignedPublicKeyBundle, invitation: InvitationV1, - created: Date - ) async throws -> SealedInvitation { - let sealed = try SealedInvitation.createV1( - sender: client.keys, - recipient: recipient, - created: created, - invitation: invitation - ) - let peerAddress = try recipient.walletAddress - try await client.publish(envelopes: [ - Envelope( - topic: .userInvite(client.address), timestamp: created, - message: sealed.serializedData()), - Envelope( - topic: .userInvite(peerAddress), timestamp: created, - message: sealed.serializedData()), - ]) - return sealed + let dm = try await findOrCreateDm(with: peerAddress) + return Conversation.dm(dm) } } diff --git a/Sources/XMTPiOS/DecodedMessage.swift b/Sources/XMTPiOS/DecodedMessage.swift index f39b82b2..eb3cb49b 100644 --- a/Sources/XMTPiOS/DecodedMessage.swift +++ b/Sources/XMTPiOS/DecodedMessage.swift @@ -1,10 +1,3 @@ -// -// DecodedMessage.swift -// -// -// Created by Pat Nakajima on 11/28/22. -// - import Foundation /// Decrypted messages from a conversation. @@ -22,7 +15,7 @@ public struct DecodedMessage: Sendable { public var sent: Date public var client: Client - + public var deliveryStatus: MessageDeliveryStatus = .published init( @@ -41,22 +34,22 @@ public struct DecodedMessage: Sendable { self.senderAddress = senderAddress self.sent = sent self.deliveryStatus = deliveryStatus -} + } - public init( - client: Client, - topic: String, - encodedContent: EncodedContent, - senderAddress: String, - sent: Date, - deliveryStatus: MessageDeliveryStatus = .published - ) { - self.client = client - self.topic = topic - self.encodedContent = encodedContent - self.senderAddress = senderAddress - self.sent = sent - self.deliveryStatus = deliveryStatus + public init( + client: Client, + topic: String, + encodedContent: EncodedContent, + senderAddress: String, + sent: Date, + deliveryStatus: MessageDeliveryStatus = .published + ) { + self.client = client + self.topic = topic + self.encodedContent = encodedContent + self.senderAddress = senderAddress + self.sent = sent + self.deliveryStatus = deliveryStatus } public func content() throws -> T { @@ -76,12 +69,17 @@ public struct DecodedMessage: Sendable { } } -public extension DecodedMessage { - static func preview(client: Client, topic: String, body: String, senderAddress: String, sent: Date) -> DecodedMessage { +extension DecodedMessage { + public static func preview( + client: Client, topic: String, body: String, senderAddress: String, + sent: Date + ) -> DecodedMessage { // swiftlint:disable force_try let encoded = try! TextCodec().encode(content: body, client: client) // swiftlint:enable force_try - return DecodedMessage(client: client, topic: topic, encodedContent: encoded, senderAddress: senderAddress, sent: sent) + return DecodedMessage( + client: client, topic: topic, encodedContent: encoded, + senderAddress: senderAddress, sent: sent) } } diff --git a/Sources/XMTPiOS/Dm.swift b/Sources/XMTPiOS/Dm.swift index 37522e7e..b4abcdd7 100644 --- a/Sources/XMTPiOS/Dm.swift +++ b/Sources/XMTPiOS/Dm.swift @@ -1,10 +1,3 @@ -// -// Dm.swift -// XMTPiOS -// -// Created by Naomi Plasterer on 10/23/24. -// - import Foundation import LibXMTP @@ -69,14 +62,6 @@ public struct Dm: Identifiable, Equatable, Hashable { } public func updateConsentState(state: ConsentState) async throws { - if client.hasV2Client { - switch state { - case .allowed: try await client.contacts.allowGroups(groupIds: [id]) - case .denied: try await client.contacts.denyGroups(groupIds: [id]) - case .unknown: () - } - } - try ffiConversation.updateConsentState(state: state.toFFI) } @@ -84,11 +69,11 @@ public struct Dm: Identifiable, Equatable, Hashable { return try ffiConversation.consentState().fromFFI } - public func processMessage(envelopeBytes: Data) async throws -> MessageV3 { + public func processMessage(messageBytes: Data) async throws -> Message { let message = try await ffiConversation.processStreamedConversationMessage( - envelopeBytes: envelopeBytes) - return MessageV3(client: client, ffiMessage: message) + envelopeBytes: messageBytes) + return Message(client: client, ffiMessage: message) } public func send(content: T, options: SendOptions? = nil) async throws @@ -181,7 +166,7 @@ public struct Dm: Identifiable, Equatable, Hashable { } do { continuation.yield( - try MessageV3( + try Message( client: self.client, ffiMessage: message ).decode()) } catch { @@ -203,47 +188,11 @@ public struct Dm: Identifiable, Equatable, Hashable { } } - public func streamDecryptedMessages() -> AsyncThrowingStream< - DecryptedMessage, Error - > { - AsyncThrowingStream { continuation in - let task = Task.detached { - self.streamHolder.stream = await self.ffiConversation.stream( - messageCallback: MessageCallback(client: self.client) { - message in - guard !Task.isCancelled else { - continuation.finish() - return - } - do { - continuation.yield( - try MessageV3( - client: self.client, ffiMessage: message - ).decrypt()) - } catch { - print("Error onMessage \(error)") - continuation.finish(throwing: error) - } - } - ) - - continuation.onTermination = { @Sendable reason in - self.streamHolder.stream?.end() - } - } - - continuation.onTermination = { @Sendable reason in - task.cancel() - self.streamHolder.stream?.end() - } - } - } - public func messages( before: Date? = nil, after: Date? = nil, limit: Int? = nil, - direction: PagingInfoSortDirection? = .descending, + direction: SortDirection? = .descending, deliveryStatus: MessageDeliveryStatus = .all ) async throws -> [DecodedMessage] { var options = FfiListMessagesOptions( @@ -296,70 +245,8 @@ public struct Dm: Identifiable, Equatable, Hashable { return try ffiConversation.findMessages(opts: options).compactMap { ffiMessage in - return MessageV3(client: self.client, ffiMessage: ffiMessage) + return Message(client: self.client, ffiMessage: ffiMessage) .decodeOrNull() } } - - public func decryptedMessages( - before: Date? = nil, - after: Date? = nil, - limit: Int? = nil, - direction: PagingInfoSortDirection? = .descending, - deliveryStatus: MessageDeliveryStatus? = .all - ) async throws -> [DecryptedMessage] { - var options = FfiListMessagesOptions( - sentBeforeNs: nil, - sentAfterNs: nil, - limit: nil, - deliveryStatus: nil, - direction: nil - ) - - if let before { - options.sentBeforeNs = Int64( - before.millisecondsSinceEpoch * 1_000_000) - } - - if let after { - options.sentAfterNs = Int64( - after.millisecondsSinceEpoch * 1_000_000) - } - - if let limit { - options.limit = Int64(limit) - } - - let status: FfiDeliveryStatus? = { - switch deliveryStatus { - case .published: - return FfiDeliveryStatus.published - case .unpublished: - return FfiDeliveryStatus.unpublished - case .failed: - return FfiDeliveryStatus.failed - default: - return nil - } - }() - - options.deliveryStatus = status - - let direction: FfiDirection? = { - switch direction { - case .ascending: - return FfiDirection.ascending - default: - return FfiDirection.descending - } - }() - - options.direction = direction - - return try ffiConversation.findMessages(opts: options).compactMap { - ffiMessage in - return MessageV3(client: self.client, ffiMessage: ffiMessage) - .decryptOrNull() - } - } } diff --git a/Sources/XMTPiOS/Extensions/Ffi.swift b/Sources/XMTPiOS/Extensions/Ffi.swift index 9da6402c..e3135c72 100644 --- a/Sources/XMTPiOS/Extensions/Ffi.swift +++ b/Sources/XMTPiOS/Extensions/Ffi.swift @@ -1,211 +1,17 @@ -// -// File.swift -// -// -// Created by Pat Nakajima on 1/16/24. -// - import Foundation import LibXMTP -// MARK: PagingInfo - -extension PagingInfo { - var toFFI: FfiPagingInfo { - FfiPagingInfo(limit: limit, cursor: cursor.toFFI, direction: direction.toFFI) - } -} - -extension FfiPagingInfo { - var fromFFI: PagingInfo { - PagingInfo.with { - $0.limit = limit - - if let cursor { - $0.cursor = cursor.fromFFI - } - - $0.direction = direction.fromFFI - } - } -} - -extension Cursor { - var toFFI: FfiCursor { - FfiCursor(digest: self.index.digest, senderTimeNs: self.index.senderTimeNs) - } -} - -extension FfiCursor { - var fromFFI: Cursor { - Cursor.with { - $0.index.digest = Data(digest) - $0.index.senderTimeNs = senderTimeNs - } - } -} - -extension PagingInfoSortDirection { - var toFFI: FfiSortDirection { - switch self { - case .ascending: - return .ascending - case .descending: - return .descending - default: - return .unspecified - } - } -} - -extension FfiSortDirection { - var fromFFI: PagingInfoSortDirection { - switch self { - case .ascending: - return .ascending - case .descending: - return .descending - default: - return .unspecified - } - } -} - -// MARK: QueryRequest - -extension QueryRequest { - var toFFI: FfiV2QueryRequest { - FfiV2QueryRequest( - contentTopics: contentTopics, - startTimeNs: startTimeNs, - endTimeNs: endTimeNs, - pagingInfo: pagingInfo.toFFI - ) - } -} - -extension FfiV2QueryRequest { - var fromFFI: QueryRequest { - QueryRequest.with { - $0.contentTopics = contentTopics - $0.startTimeNs = startTimeNs - $0.endTimeNs = endTimeNs - $0.pagingInfo = pagingInfo?.fromFFI ?? PagingInfo() - } - } -} - -// MARK: BatchQueryRequest - -extension BatchQueryRequest { - var toFFI: FfiV2BatchQueryRequest { - FfiV2BatchQueryRequest(requests: requests.map(\.toFFI)) - } -} - -extension FfiV2BatchQueryRequest { - var fromFFI: BatchQueryRequest { - BatchQueryRequest.with { - $0.requests = requests.map(\.fromFFI) - } - } -} - -// MARK: QueryResponse - -extension QueryResponse { - var toFFI: FfiV2QueryResponse { - FfiV2QueryResponse(envelopes: envelopes.map(\.toFFI), pagingInfo: nil) - } -} - -extension FfiV2QueryResponse { - var fromFFI: QueryResponse { - QueryResponse.with { - $0.envelopes = envelopes.map(\.fromFFI) - $0.pagingInfo = pagingInfo?.fromFFI ?? PagingInfo() - } - } -} - -// MARK: BatchQueryResponse - -extension BatchQueryResponse { - var toFFI: FfiV2BatchQueryResponse { - FfiV2BatchQueryResponse(responses: responses.map(\.toFFI)) - } -} - -extension FfiV2BatchQueryResponse { - var fromFFI: BatchQueryResponse { - BatchQueryResponse.with { - $0.responses = responses.map(\.fromFFI) - } - } -} - -// MARK: Envelope - -extension Envelope { - var toFFI: FfiEnvelope { - FfiEnvelope(contentTopic: contentTopic, timestampNs: timestampNs, message: message) - } -} - -extension FfiEnvelope { - var fromFFI: Envelope { - Envelope.with { - $0.contentTopic = contentTopic - $0.timestampNs = timestampNs - $0.message = Data(message) - } - } -} - -// MARK: PublishRequest - -extension PublishRequest { - var toFFI: FfiPublishRequest { - FfiPublishRequest(envelopes: envelopes.map(\.toFFI)) - } -} - -extension FfiPublishRequest { - var fromFFI: PublishRequest { - PublishRequest.with { - $0.envelopes = envelopes.map(\.fromFFI) - } - } -} - -// MARK: SubscribeRequest -extension SubscribeRequest { - var toFFI: FfiV2SubscribeRequest { - FfiV2SubscribeRequest(contentTopics: contentTopics) - } -} - -extension FfiV2SubscribeRequest { - var fromFFI: SubscribeRequest { - SubscribeRequest.with { - $0.contentTopics = contentTopics - } - } -} - -// MARK: Group - extension FfiConversation { func groupFromFFI(client: Client) -> Group { Group(ffiGroup: self, client: client) } - + func dmFromFFI(client: Client) -> Dm { Dm(ffiConversation: self, client: client) } - + func toConversation(client: Client) throws -> Conversation { - if (try groupMetadata().conversationType() == "dm") { + if try groupMetadata().conversationType() == "dm" { return Conversation.dm(self.dmFromFFI(client: client)) } else { return Conversation.group(self.groupFromFFI(client: client)) @@ -220,8 +26,8 @@ extension FfiConversationMember { } extension ConsentState { - var toFFI: FfiConsentState{ - switch (self) { + var toFFI: FfiConsentState { + switch self { case .allowed: return FfiConsentState.allowed case .denied: return FfiConsentState.denied default: return FfiConsentState.unknown @@ -230,8 +36,8 @@ extension ConsentState { } extension FfiConsentState { - var fromFFI: ConsentState{ - switch (self) { + var fromFFI: ConsentState { + switch self { case .allowed: return ConsentState.allowed case .denied: return ConsentState.denied default: return ConsentState.unknown @@ -240,9 +46,9 @@ extension FfiConsentState { } extension EntryType { - var toFFI: FfiConsentEntityType{ - switch (self) { - case .group_id: return FfiConsentEntityType.conversationId + var toFFI: FfiConsentEntityType { + switch self { + case .conversation_id: return FfiConsentEntityType.conversationId case .inbox_id: return FfiConsentEntityType.inboxId case .address: return FfiConsentEntityType.address } @@ -251,6 +57,8 @@ extension EntryType { extension ConsentListEntry { var toFFI: FfiConsent { - FfiConsent(entityType: entryType.toFFI, state: consentType.toFFI, entity: value) + FfiConsent( + entityType: entryType.toFFI, state: consentType.toFFI, entity: value + ) } } diff --git a/Sources/XMTPiOS/Frames/FramesClient.swift b/Sources/XMTPiOS/Frames/FramesClient.swift deleted file mode 100644 index 0fea2726..00000000 --- a/Sources/XMTPiOS/Frames/FramesClient.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// FramesClient.swift -// -// -// Created by Alex Risch on 3/28/24. -// - -import Foundation -import LibXMTP - -public typealias FrameActionBody = Xmtp_MessageContents_FrameActionBody -public typealias FrameAction = Xmtp_MessageContents_FrameAction - -enum FramesClientError: Error { - case missingConversationTopic - case missingTarget - case readMetadataFailed(message: String, code: Int) - case postFrameFailed(message: String, code: Int) -} - -public class FramesClient { - var xmtpClient: Client - public var proxy: OpenFramesProxy - - public init(xmtpClient: Client, proxy: OpenFramesProxy? = nil) { - self.xmtpClient = xmtpClient - self.proxy = proxy ?? OpenFramesProxy() - } - - public func signFrameAction(inputs: FrameActionInputs) async throws -> FramePostPayload { - let opaqueConversationIdentifier = try self.buildOpaqueIdentifier(inputs: inputs) - let frameUrl = inputs.frameUrl - let buttonIndex = inputs.buttonIndex - let inputText = inputs.inputText ?? "" - let state = inputs.state ?? "" - let now = Date().timeIntervalSince1970 - let timestamp = now - - var toSign = FrameActionBody() - toSign.frameURL = frameUrl - toSign.buttonIndex = buttonIndex - toSign.opaqueConversationIdentifier = opaqueConversationIdentifier - toSign.timestamp = UInt64(timestamp) - toSign.inputText = inputText - toSign.unixTimestamp = UInt32(now) - toSign.state = state - - let signedAction = try await self.buildSignedFrameAction(actionBodyInputs: toSign) - - let untrustedData = FramePostUntrustedData( - url: frameUrl, timestamp: UInt64(now), buttonIndex: buttonIndex, inputText: inputText, state: state, walletAddress: self.xmtpClient.address, opaqueConversationIdentifier: opaqueConversationIdentifier, unixTimestamp: UInt32(now) - ) - - - let trustedData = FramePostTrustedData(messageBytes: signedAction.base64EncodedString()) - - let payload = FramePostPayload( - clientProtocol: "xmtp@\(PROTOCOL_VERSION)", untrustedData: untrustedData, trustedData: trustedData - ) - - return payload - } - - private func signDigest(digest: Data) async throws -> Signature { - let key = try self.xmtpClient.keys.identityKey - let privateKey = try PrivateKey(key) - let signature = try await privateKey.sign(Data(digest)) - return signature - } - - private func getPublicKeyBundle() async throws -> PublicKeyBundle { - let bundleBytes = try self.xmtpClient.publicKeyBundle; - return try PublicKeyBundle(bundleBytes); - } - - private func buildSignedFrameAction(actionBodyInputs: FrameActionBody) async throws -> Data { - - let digest = sha256(input: try actionBodyInputs.serializedData()) - let signature = try await self.signDigest(digest: digest) - - let publicKeyBundle = try await self.getPublicKeyBundle() - var frameAction = FrameAction() - frameAction.actionBody = try actionBodyInputs.serializedData() - frameAction.signature = signature - frameAction.signedPublicKeyBundle = try SignedPublicKeyBundle(publicKeyBundle) - - return try frameAction.serializedData() - } - - private func buildOpaqueIdentifier(inputs: FrameActionInputs) throws -> String { - switch inputs.conversationInputs { - case .group(let groupInputs): - let combined = groupInputs.groupId + groupInputs.groupSecret - let digest = sha256(input: combined) - return digest.base64EncodedString() - case .dm(let dmInputs): - guard let conversationTopic = dmInputs.conversationTopic else { - throw FramesClientError.missingConversationTopic - } - guard let combined = (conversationTopic.lowercased() + dmInputs.participantAccountAddresses.map { $0.lowercased() }.sorted().joined()).data(using: .utf8) else { - throw FramesClientError.missingConversationTopic - } - let digest = sha256(input: combined) - return digest.base64EncodedString() - } - } - -} diff --git a/Sources/XMTPiOS/Frames/FramesConstants.swift b/Sources/XMTPiOS/Frames/FramesConstants.swift deleted file mode 100644 index 976b8746..00000000 --- a/Sources/XMTPiOS/Frames/FramesConstants.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// File.swift -// -// -// Created by Alex Risch on 3/28/24. -// - -import Foundation - -let OPEN_FRAMES_PROXY_URL = "https://frames.xmtp.chat/" - -let PROTOCOL_VERSION = "2024-02-09" diff --git a/Sources/XMTPiOS/Frames/FramesTypes.swift b/Sources/XMTPiOS/Frames/FramesTypes.swift deleted file mode 100644 index 1f0819dc..00000000 --- a/Sources/XMTPiOS/Frames/FramesTypes.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// File.swift -// -// -// Created by Alex Risch on 3/28/24. -// - -import Foundation - -typealias AcceptedFrameClients = [String: String] - -enum OpenFrameButton: Codable { - case link(target: String, label: String) - case mint(target: String, label: String) - case post(target: String?, label: String) - case postRedirect(target: String?, label: String) - - enum CodingKeys: CodingKey { - case action, target, label - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let action = try container.decode(String.self, forKey: .action) - guard let target = try container.decodeIfPresent(String.self, forKey: .target) else { - throw FramesClientError.missingTarget - } - let label = try container.decode(String.self, forKey: .label) - - switch action { - case "link": - self = .link(target: target, label: label) - case "mint": - self = .mint(target: target, label: label) - case "post": - self = .post(target: target, label: label) - case "post_redirect": - self = .postRedirect(target: target, label: label) - default: - throw DecodingError.dataCorruptedError(forKey: .action, in: container, debugDescription: "Invalid action value") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .link(let target, let label): - try container.encode("link", forKey: .action) - try container.encode(target, forKey: .target) - try container.encode(label, forKey: .label) - case .mint(let target, let label): - try container.encode("mint", forKey: .action) - try container.encode(target, forKey: .target) - try container.encode(label, forKey: .label) - case .post(let target, let label): - try container.encode("post", forKey: .action) - try container.encode(target, forKey: .target) - try container.encode(label, forKey: .label) - case .postRedirect(let target, let label): - try container.encode("post_redirect", forKey: .action) - try container.encode(target, forKey: .target) - try container.encode(label, forKey: .label) - } - } -} - -public struct OpenFrameImage: Codable { - let content: String - let aspectRatio: AspectRatio? - let alt: String? -} - -public enum AspectRatio: String, Codable { - case ratio_1_91_1 = "1.91.1" - case ratio_1_1 = "1:1" -} - -public struct TextInput: Codable { - let content: String -} - -struct OpenFrameResult: Codable { - let acceptedClients: AcceptedFrameClients - let image: OpenFrameImage - let postUrl: String? - let textInput: TextInput? - let buttons: [String: OpenFrameButton]? - let ogImage: String - let state: String? -}; - -public struct GetMetadataResponse: Codable { - let url: String - public let extractedTags: [String: String] -} - -public struct PostRedirectResponse: Codable { - let originalUrl: String - let redirectedTo: String -}; - -public struct OpenFramesUntrustedData: Codable { - let url: String - let timestamp: Int - let buttonIndex: Int - let inputText: String? - let state: String? -} - -public typealias FramesApiRedirectResponse = PostRedirectResponse; - -public struct FramePostUntrustedData: Codable { - let url: String - let timestamp: UInt64 - let buttonIndex: Int32 - let inputText: String? - let state: String? - let walletAddress: String - let opaqueConversationIdentifier: String - let unixTimestamp: UInt32 -} - -public struct FramePostTrustedData: Codable { - let messageBytes: String -} - -public struct FramePostPayload: Codable { - let clientProtocol: String - let untrustedData: FramePostUntrustedData - let trustedData: FramePostTrustedData -} - -public struct DmActionInputs: Codable { - public let conversationTopic: String? - public let participantAccountAddresses: [String] - public init(conversationTopic: String? = nil, participantAccountAddresses: [String]) { - self.conversationTopic = conversationTopic - self.participantAccountAddresses = participantAccountAddresses - } -} - -public struct GroupActionInputs: Codable { - let groupId: Data - let groupSecret: Data -} - -public enum ConversationActionInputs: Codable { - case dm(DmActionInputs) - case group(GroupActionInputs) -} - -public struct FrameActionInputs: Codable { - let frameUrl: String - let buttonIndex: Int32 - let inputText: String? - let state: String? - let conversationInputs: ConversationActionInputs - public init(frameUrl: String, buttonIndex: Int32, inputText: String?, state: String?, conversationInputs: ConversationActionInputs) { - self.frameUrl = frameUrl - self.buttonIndex = buttonIndex - self.inputText = inputText - self.state = state - self.conversationInputs = conversationInputs - } -} - diff --git a/Sources/XMTPiOS/Frames/OpenFramesProxy.swift b/Sources/XMTPiOS/Frames/OpenFramesProxy.swift deleted file mode 100644 index 7f00c07b..00000000 --- a/Sources/XMTPiOS/Frames/OpenFramesProxy.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// File.swift -// -// -// Created by Alex Risch on 3/28/24. -// - -import Foundation - -public class OpenFramesProxy { - let inner: ProxyClient - - init(baseUrl: String = OPEN_FRAMES_PROXY_URL) { - self.inner = ProxyClient(baseUrl: baseUrl); - } - - public func readMetadata(url: String) async throws -> GetMetadataResponse { - return try await self.inner.readMetadata(url: url); - } - - public func post(url: String, payload: FramePostPayload) async throws -> GetMetadataResponse { - return try await self.inner.post(url: url, payload: payload); - } - - public func postRedirect( - url: String, - payload: FramePostPayload - ) async throws -> FramesApiRedirectResponse { - return try await self.inner.postRedirect(url: url, payload: payload); - } - - public func mediaUrl(url: String) async throws -> String { - if url.hasPrefix("data:") { - return url - } - return self.inner.mediaUrl(url: url); - } -} diff --git a/Sources/XMTPiOS/Frames/ProxyClient.swift b/Sources/XMTPiOS/Frames/ProxyClient.swift deleted file mode 100644 index 6a7330b1..00000000 --- a/Sources/XMTPiOS/Frames/ProxyClient.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// File.swift -// -// -// Created by Alex Risch on 3/28/24. -// - -import Foundation - -struct Metadata: Codable { - let title: String - let description: String - let imageUrl: String -} - -class ProxyClient { - var baseUrl: String - - init(baseUrl: String) { - self.baseUrl = baseUrl - } - - func readMetadata(url: String) async throws -> GetMetadataResponse { - let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let fullUrl = "\(self.baseUrl)?url=\(encodedUrl)" - guard let url = URL(string: fullUrl) else { - throw URLError(.badURL) - } - - let (data, response) = try await URLSession.shared.data(from: url) - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpResponse.statusCode == 200 else { - throw FramesClientError.readMetadataFailed(message: "Failed to read metadata for \(url)", code: httpResponse.statusCode) - } - - let decoder = JSONDecoder() - let metadataResponse: GetMetadataResponse = try decoder.decode(GetMetadataResponse.self, from: data) - return metadataResponse - } - - func post(url: String, payload: Codable) async throws -> GetMetadataResponse { - let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let fullUrl = "\(self.baseUrl)?url=\(encodedUrl)" - guard let url = URL(string: fullUrl) else { - throw URLError(.badURL) - } - let encoder = JSONEncoder() - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try encoder.encode(payload) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw URLError(.badServerResponse) - } - - let decoder = JSONDecoder() - let metadataResponse = try decoder.decode(GetMetadataResponse.self, from: data) - return metadataResponse - } - - func postRedirect(url: String, payload: Codable) async throws -> PostRedirectResponse { - let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let fullUrl = "\(self.baseUrl)redirect?url=\(encodedUrl)" - guard let url = URL(string: fullUrl) else { - throw URLError(.badURL) - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: payload) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - guard httpResponse.statusCode == 200 else { - throw FramesClientError.postFrameFailed(message: "Failed to post to frame \(url)", code: httpResponse.statusCode) - } - - let decoder = JSONDecoder() - let postRedirectResponse = try decoder.decode(PostRedirectResponse.self, from: data) - return postRedirectResponse - } - - func mediaUrl(url: String) -> String { - let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let result = "\(self.baseUrl)media?url=\(encodedUrl)" - return result - } -} diff --git a/Sources/XMTPiOS/Group.swift b/Sources/XMTPiOS/Group.swift index cef154a6..f69200fc 100644 --- a/Sources/XMTPiOS/Group.swift +++ b/Sources/XMTPiOS/Group.swift @@ -1,10 +1,3 @@ -// -// Group.swift -// -// -// Created by Pat Nakajima on 2/1/24. -// - import Foundation import LibXMTP @@ -268,14 +261,6 @@ public struct Group: Identifiable, Equatable, Hashable { } public func updateConsentState(state: ConsentState) async throws { - if client.hasV2Client { - switch state { - case .allowed: try await client.contacts.allowGroups(groupIds: [id]) - case .denied: try await client.contacts.denyGroups(groupIds: [id]) - case .unknown: () - } - } - try ffiGroup.updateConsentState(state: state.toFFI) } @@ -283,10 +268,10 @@ public struct Group: Identifiable, Equatable, Hashable { return try ffiGroup.consentState().fromFFI } - public func processMessage(envelopeBytes: Data) async throws -> MessageV3 { + public func processMessage(messageBytes: Data) async throws -> Message { let message = try await ffiGroup.processStreamedConversationMessage( - envelopeBytes: envelopeBytes) - return MessageV3(client: client, ffiMessage: message) + envelopeBytes: messageBytes) + return Message(client: client, ffiMessage: message) } public func send(content: T, options: SendOptions? = nil) async throws @@ -379,7 +364,7 @@ public struct Group: Identifiable, Equatable, Hashable { } do { continuation.yield( - try MessageV3( + try Message( client: self.client, ffiMessage: message ).decode()) } catch { @@ -401,47 +386,11 @@ public struct Group: Identifiable, Equatable, Hashable { } } - public func streamDecryptedMessages() -> AsyncThrowingStream< - DecryptedMessage, Error - > { - AsyncThrowingStream { continuation in - let task = Task.detached { - self.streamHolder.stream = await self.ffiGroup.stream( - messageCallback: MessageCallback(client: self.client) { - message in - guard !Task.isCancelled else { - continuation.finish() - return - } - do { - continuation.yield( - try MessageV3( - client: self.client, ffiMessage: message - ).decrypt()) - } catch { - print("Error onMessage \(error)") - continuation.finish(throwing: error) - } - } - ) - - continuation.onTermination = { @Sendable reason in - self.streamHolder.stream?.end() - } - } - - continuation.onTermination = { @Sendable reason in - task.cancel() - self.streamHolder.stream?.end() - } - } - } - public func messages( before: Date? = nil, after: Date? = nil, limit: Int? = nil, - direction: PagingInfoSortDirection? = .descending, + direction: SortDirection? = .descending, deliveryStatus: MessageDeliveryStatus = .all ) async throws -> [DecodedMessage] { var options = FfiListMessagesOptions( @@ -494,70 +443,8 @@ public struct Group: Identifiable, Equatable, Hashable { return try ffiGroup.findMessages(opts: options).compactMap { ffiMessage in - return MessageV3(client: self.client, ffiMessage: ffiMessage) + return Message(client: self.client, ffiMessage: ffiMessage) .decodeOrNull() } } - - public func decryptedMessages( - before: Date? = nil, - after: Date? = nil, - limit: Int? = nil, - direction: PagingInfoSortDirection? = .descending, - deliveryStatus: MessageDeliveryStatus? = .all - ) async throws -> [DecryptedMessage] { - var options = FfiListMessagesOptions( - sentBeforeNs: nil, - sentAfterNs: nil, - limit: nil, - deliveryStatus: nil, - direction: nil - ) - - if let before { - options.sentBeforeNs = Int64( - before.millisecondsSinceEpoch * 1_000_000) - } - - if let after { - options.sentAfterNs = Int64( - after.millisecondsSinceEpoch * 1_000_000) - } - - if let limit { - options.limit = Int64(limit) - } - - let status: FfiDeliveryStatus? = { - switch deliveryStatus { - case .published: - return FfiDeliveryStatus.published - case .unpublished: - return FfiDeliveryStatus.unpublished - case .failed: - return FfiDeliveryStatus.failed - default: - return nil - } - }() - - options.deliveryStatus = status - - let direction: FfiDirection? = { - switch direction { - case .ascending: - return FfiDirection.ascending - default: - return FfiDirection.descending - } - }() - - options.direction = direction - - return try ffiGroup.findMessages(opts: options).compactMap { - ffiMessage in - return MessageV3(client: self.client, ffiMessage: ffiMessage) - .decryptOrNull() - } - } } diff --git a/Sources/XMTPiOS/Mls/InboxState.swift b/Sources/XMTPiOS/Libxmtp/InboxState.swift similarity index 100% rename from Sources/XMTPiOS/Mls/InboxState.swift rename to Sources/XMTPiOS/Libxmtp/InboxState.swift diff --git a/Sources/XMTPiOS/Mls/Installation.swift b/Sources/XMTPiOS/Libxmtp/Installation.swift similarity index 100% rename from Sources/XMTPiOS/Mls/Installation.swift rename to Sources/XMTPiOS/Libxmtp/Installation.swift diff --git a/Sources/XMTPiOS/Mls/Member.swift b/Sources/XMTPiOS/Libxmtp/Member.swift similarity index 100% rename from Sources/XMTPiOS/Mls/Member.swift rename to Sources/XMTPiOS/Libxmtp/Member.swift diff --git a/Sources/XMTPiOS/Libxmtp/Message.swift b/Sources/XMTPiOS/Libxmtp/Message.swift new file mode 100644 index 00000000..69302593 --- /dev/null +++ b/Sources/XMTPiOS/Libxmtp/Message.swift @@ -0,0 +1,94 @@ +import Foundation +import LibXMTP + +enum MessageError: Error { + case decodeError(String) +} + +public enum MessageDeliveryStatus: String, RawRepresentable, Sendable { + case all, + published, + unpublished, + failed +} + +public enum SortDirection { + case descending, ascending +} + +public struct Message: Identifiable { + let client: Client + let ffiMessage: FfiMessage + + init(client: Client, ffiMessage: FfiMessage) { + self.client = client + self.ffiMessage = ffiMessage + } + + public var id: String { + return ffiMessage.id.toHex + } + + var convoId: String { + return ffiMessage.convoId.toHex + } + + var senderInboxId: String { + return ffiMessage.senderInboxId + } + + var sentAt: Date { + return Date( + timeIntervalSince1970: TimeInterval(ffiMessage.sentAtNs) + / 1_000_000_000) + } + + var deliveryStatus: MessageDeliveryStatus { + switch ffiMessage.deliveryStatus { + case .unpublished: + return .unpublished + case .published: + return .published + case .failed: + return .failed + } + } + + public func decode() throws -> DecodedMessage { + do { + let encodedContent = try EncodedContent( + serializedData: ffiMessage.content) + + let decodedMessage = DecodedMessage( + id: id, + client: client, + topic: Topic.groupMessage(convoId).description, + encodedContent: encodedContent, + senderAddress: senderInboxId, + sent: sentAt, + deliveryStatus: deliveryStatus + ) + + if decodedMessage.encodedContent.type == ContentTypeGroupUpdated + && ffiMessage.kind != .membershipChange + { + throw MessageError.decodeError( + "Error decoding group membership change") + } + + return decodedMessage + } catch { + throw MessageError.decodeError( + "Error decoding message: \(error.localizedDescription)") + } + } + + public func decodeOrNull() -> DecodedMessage? { + do { + return try decode() + } catch { + print("MESSAGE: discarding message that failed to decode", error) + return nil + } + } +} diff --git a/Sources/XMTPiOS/Mls/PermissionPolicySet.swift b/Sources/XMTPiOS/Libxmtp/PermissionPolicySet.swift similarity index 100% rename from Sources/XMTPiOS/Mls/PermissionPolicySet.swift rename to Sources/XMTPiOS/Libxmtp/PermissionPolicySet.swift diff --git a/Sources/XMTPiOS/Messages/AuthData.swift b/Sources/XMTPiOS/Messages/AuthData.swift deleted file mode 100644 index bc5207b7..00000000 --- a/Sources/XMTPiOS/Messages/AuthData.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// AuthData.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -typealias AuthData = Xmtp_MessageApi_V1_AuthData - -extension AuthData { - init(walletAddress: String, timestamp: Date? = nil) { - self.init() - walletAddr = walletAddress - - let timestamp = timestamp ?? Date() - createdNs = UInt64(timestamp.millisecondsSinceEpoch * 1_000_000) - } -} diff --git a/Sources/XMTPiOS/Messages/ContactBundle.swift b/Sources/XMTPiOS/Messages/ContactBundle.swift deleted file mode 100644 index ea93486e..00000000 --- a/Sources/XMTPiOS/Messages/ContactBundle.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// ContactBundle.swift -// -// -// Created by Pat Nakajima on 11/23/22. -// - -import web3 -import LibXMTP - -typealias ContactBundle = Xmtp_MessageContents_ContactBundle -typealias ContactBundleV1 = Xmtp_MessageContents_ContactBundleV1 -typealias ContactBundleV2 = Xmtp_MessageContents_ContactBundleV2 - -enum ContactBundleError: Error { - case invalidVersion, notFound -} - -extension ContactBundle { - static func from(envelope: Envelope) throws -> ContactBundle { - let data = envelope.message - - var contactBundle = ContactBundle() - - // Try to deserialize legacy v1 bundle - let publicKeyBundle = try PublicKeyBundle(serializedData: data) - - contactBundle.v1.keyBundle = publicKeyBundle - - // It's not a legacy bundle so just deserialize as a ContactBundle - if contactBundle.v1.keyBundle.identityKey.secp256K1Uncompressed.bytes.isEmpty { - try contactBundle.merge(serializedData: data) - } - - return contactBundle - } - - func toPublicKeyBundle() throws -> PublicKeyBundle { - switch version { - case .v1: - return v1.keyBundle - case .v2: - return try PublicKeyBundle(v2.keyBundle) - default: - throw ContactBundleError.invalidVersion - } - } - - func toSignedPublicKeyBundle() throws -> SignedPublicKeyBundle { - switch version { - case .v1: - return try SignedPublicKeyBundle(v1.keyBundle) - case .v2: - return v2.keyBundle - case .none: - throw ContactBundleError.invalidVersion - } - } - - // swiftlint:disable no_optional_try - - var walletAddress: String? { - switch version { - case .v1: - if let key = try? v1.keyBundle.identityKey.recoverWalletSignerPublicKey() { - return KeyUtilx.generateAddress(from: key.secp256K1Uncompressed.bytes).toChecksumAddress() - } - - return nil - case .v2: - if let key = try? v2.keyBundle.identityKey.recoverWalletSignerPublicKey() { - return KeyUtilx.generateAddress(from: key.secp256K1Uncompressed.bytes).toChecksumAddress() - } - - return nil - case .none: - return nil - } - } - - var identityAddress: String? { - switch version { - case .v1: - return v1.keyBundle.identityKey.walletAddress - case .v2: - let publicKey = try? PublicKey(v2.keyBundle.identityKey) - return publicKey?.walletAddress - case .none: - return nil - } - } - - // swiftlint:enable no_optional_try -} diff --git a/Sources/XMTPiOS/Messages/DecryptedMessage.swift b/Sources/XMTPiOS/Messages/DecryptedMessage.swift deleted file mode 100644 index 69a1a57a..00000000 --- a/Sources/XMTPiOS/Messages/DecryptedMessage.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// DecryptedMessage.swift -// -// -// Created by Pat Nakajima on 11/14/23. -// - -import Foundation - -public struct DecryptedMessage { - public var id: String - public var encodedContent: EncodedContent - public var senderAddress: String - public var sentAt: Date - public var topic: String = "" - public var deliveryStatus: MessageDeliveryStatus = .published -} diff --git a/Sources/XMTPiOS/Messages/EncryptedPrivateKeyBundle.swift b/Sources/XMTPiOS/Messages/EncryptedPrivateKeyBundle.swift deleted file mode 100644 index 1684d31d..00000000 --- a/Sources/XMTPiOS/Messages/EncryptedPrivateKeyBundle.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// EncryptedPrivateKeyBundle.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -typealias EncryptedPrivateKeyBundle = Xmtp_MessageContents_EncryptedPrivateKeyBundle - -extension EncryptedPrivateKeyBundle { - func decrypted(with key: SigningKey, preEnableIdentityCallback: PreEventCallback? = nil) async throws -> PrivateKeyBundle { - try await preEnableIdentityCallback?() - let signature = try await key.sign(message: Signature.enableIdentityText(key: v1.walletPreKey)) - let message = try Crypto.decrypt(signature.rawDataWithNormalizedRecovery, v1.ciphertext) - - return try PrivateKeyBundle(serializedData: message) - } -} diff --git a/Sources/XMTPiOS/Messages/Envelope.swift b/Sources/XMTPiOS/Messages/Envelope.swift deleted file mode 100644 index 8b04e281..00000000 --- a/Sources/XMTPiOS/Messages/Envelope.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Envelope.swift -// -// -// Created by Pat Nakajima on 11/26/22. -// - -import Foundation - -public typealias Envelope = Xmtp_MessageApi_V1_Envelope - -extension Envelope { - init(topic: Topic, timestamp: Date, message: Data) { - self.init() - contentTopic = topic.description - timestampNs = UInt64(timestamp.millisecondsSinceEpoch * 1_000_000) - self.message = message - } - - init(topic: String, timestamp: Date, message: Data) { - self.init() - contentTopic = topic - timestampNs = UInt64(timestamp.millisecondsSinceEpoch * 1_000_000) - self.message = message - } -} diff --git a/Sources/XMTPiOS/Messages/Invitation.swift b/Sources/XMTPiOS/Messages/Invitation.swift deleted file mode 100644 index 15d2ee8b..00000000 --- a/Sources/XMTPiOS/Messages/Invitation.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// Invitation.swift -// - -import Foundation - -/// Handles topic generation for conversations. -public typealias InvitationV1 = Xmtp_MessageContents_InvitationV1 -public typealias ConsentProofPayload = Xmtp_MessageContents_ConsentProofPayload -public typealias ConsentProofPayloadVersion = Xmtp_MessageContents_ConsentProofPayloadVersion - - - -extension InvitationV1 { - static func createDeterministic( - sender: PrivateKeyBundleV2, - recipient: SignedPublicKeyBundle, - context: InvitationV1.Context? = nil, - consentProofPayload: ConsentProofPayload? = nil - ) throws -> InvitationV1 { - let context = context ?? InvitationV1.Context() - let myAddress = try sender.toV1().walletAddress - let theirAddress = try recipient.walletAddress - - let secret = try sender.sharedSecret( - peer: recipient, - myPreKey: sender.preKeys[0].publicKey, - isRecipient: myAddress < theirAddress) - let addresses = [myAddress, theirAddress].sorted() - let msg = "\(context.conversationID)\(addresses.joined(separator: ","))" - let topicId = try Crypto.calculateMac(Data(msg.utf8), secret).toHex - let topic = Topic.directMessageV2(topicId) - - let keyMaterial = try Crypto.deriveKey( - secret: secret, - nonce: Data("__XMTP__INVITATION__SALT__XMTP__".utf8), - info: Data((["0"] + addresses).joined(separator: "|").utf8)) - - var aes256GcmHkdfSha256 = InvitationV1.Aes256gcmHkdfsha256() - aes256GcmHkdfSha256.keyMaterial = Data(keyMaterial) - return try InvitationV1( - topic: topic, - context: context, - aes256GcmHkdfSha256: aes256GcmHkdfSha256, - consentProof: consentProofPayload) - } - - init(topic: Topic, context: InvitationV1.Context? = nil, aes256GcmHkdfSha256: InvitationV1.Aes256gcmHkdfsha256, consentProof: ConsentProofPayload? = nil) throws { - self.init() - - self.topic = topic.description - - if let context { - self.context = context - } - if let consentProof { - self.consentProof = consentProof - } - - self.aes256GcmHkdfSha256 = aes256GcmHkdfSha256 - } -} - -/// Allows for additional data to be attached to V2 conversations -public extension InvitationV1.Context { - init(conversationID: String = "", metadata: [String: String] = [:]) { - self.init() - self.conversationID = conversationID - self.metadata = metadata - } -} - -extension ConsentProofPayload: Codable { - enum CodingKeys: CodingKey { - case signature, timestamp, payloadVersion - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(signature, forKey: .signature) - try container.encode(timestamp, forKey: .timestamp) - try container.encode(payloadVersion, forKey: .payloadVersion) - } - - public init(from decoder: Decoder) throws { - self.init() - - let container = try decoder.container(keyedBy: CodingKeys.self) - signature = try container.decode(String.self, forKey: .signature) - timestamp = try container.decode(UInt64.self, forKey: .timestamp) - payloadVersion = try container.decode(Xmtp_MessageContents_ConsentProofPayloadVersion.self, forKey: .payloadVersion) - } -} - -extension ConsentProofPayloadVersion: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let rawValue = try container.decode(Int.self) - self = ConsentProofPayloadVersion(rawValue: rawValue) ?? ConsentProofPayloadVersion.UNRECOGNIZED(0) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(self.rawValue) - } -} diff --git a/Sources/XMTPiOS/Messages/Message.swift b/Sources/XMTPiOS/Messages/Message.swift deleted file mode 100644 index 01124160..00000000 --- a/Sources/XMTPiOS/Messages/Message.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Message.swift -// -// -// Created by Pat Nakajima on 11/27/22. -// - -/// Handles encryption/decryption for communicating data in conversations -public typealias Message = Xmtp_MessageContents_Message - -public enum MessageVersion: String, RawRepresentable { - case v1, - v2 -} - -public enum MessageDeliveryStatus: String, RawRepresentable, Sendable { - case all, - published, - unpublished, - failed -} - -extension Message { - init(v1: MessageV1) { - self.init() - self.v1 = v1 - } - - init(v2: MessageV2) { - self.init() - self.v2 = v2 - } -} diff --git a/Sources/XMTPiOS/Messages/MessageHeaderV1.swift b/Sources/XMTPiOS/Messages/MessageHeaderV1.swift deleted file mode 100644 index 25c3682f..00000000 --- a/Sources/XMTPiOS/Messages/MessageHeaderV1.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MessageHeaderV1.swift -// -// -// Created by Pat Nakajima on 11/27/22. -// - -import Foundation - -typealias MessageHeaderV1 = Xmtp_MessageContents_MessageHeaderV1 - -extension MessageHeaderV1 { - init(sender: PublicKeyBundle, recipient: PublicKeyBundle, timestamp: UInt64) { - self.init() - self.sender = sender - self.recipient = recipient - self.timestamp = timestamp - } -} diff --git a/Sources/XMTPiOS/Messages/MessageHeaderV2.swift b/Sources/XMTPiOS/Messages/MessageHeaderV2.swift deleted file mode 100644 index d967525e..00000000 --- a/Sources/XMTPiOS/Messages/MessageHeaderV2.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// MessageHeaderV2.swift -// -// -// Created by Pat Nakajima on 12/5/22. -// - -import Foundation - -typealias MessageHeaderV2 = Xmtp_MessageContents_MessageHeaderV2 - -extension MessageHeaderV2 { - init(topic: String, created: Date) { - self.init() - self.topic = topic - createdNs = UInt64(created.millisecondsSinceEpoch * 1_000_000) - } -} diff --git a/Sources/XMTPiOS/Messages/MessageV1.swift b/Sources/XMTPiOS/Messages/MessageV1.swift deleted file mode 100644 index 6a075ca7..00000000 --- a/Sources/XMTPiOS/Messages/MessageV1.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// MessageV1.swift -// -// -// Created by Pat Nakajima on 11/26/22. -// - -import Foundation - -typealias MessageV1 = Xmtp_MessageContents_MessageV1 - -enum MessageV1Error: Error { - case cannotDecodeFromBytes -} - -extension MessageV1 { - static func encode(sender: PrivateKeyBundleV1, recipient: PublicKeyBundle, message: Data, timestamp: Date) throws -> MessageV1 { - let secret = try sender.sharedSecret( - peer: recipient, - myPreKey: sender.preKeys[0].publicKey, - isRecipient: false - ) - - let header = MessageHeaderV1( - sender: sender.toPublicKeyBundle(), - recipient: recipient, - timestamp: UInt64(timestamp.millisecondsSinceEpoch) - ) - - let headerBytes = try header.serializedData() - let ciphertext = try Crypto.encrypt(secret, message, additionalData: headerBytes) - - return MessageV1(headerBytes: headerBytes, ciphertext: ciphertext) - } - - static func fromBytes(_ bytes: Data) throws -> MessageV1 { - let message = try Message(serializedData: bytes) - var headerBytes: Data - var ciphertext: CipherText - - switch message.version { - case .v1: - headerBytes = message.v1.headerBytes - ciphertext = message.v1.ciphertext - case .v2: - headerBytes = message.v2.headerBytes - ciphertext = message.v2.ciphertext - default: - throw MessageV1Error.cannotDecodeFromBytes - } - - return MessageV1(headerBytes: headerBytes, ciphertext: ciphertext) - } - - init(headerBytes: Data, ciphertext: CipherText) { - self.init() - self.headerBytes = headerBytes - self.ciphertext = ciphertext - } - - var header: MessageHeaderV1 { - get throws { - do { - return try MessageHeaderV1(serializedData: headerBytes) - } catch { - print("Error deserializing MessageHeaderV1 \(error)") - throw error - } - } - } - - var senderAddress: String? { - do { - let senderKey = try header.sender.identityKey.recoverWalletSignerPublicKey() - return senderKey.walletAddress - } catch { - print("Error getting sender address: \(error)") - return nil - } - } - - var sentAt: Date { - // swiftlint:disable force_try - try! Date(timeIntervalSince1970: Double(header.timestamp / 1000)) - // swiftlint:enable force_try - } - - var recipientAddress: String? { - do { - let recipientKey = try header.recipient.identityKey.recoverWalletSignerPublicKey() - - return recipientKey.walletAddress - } catch { - print("Error getting recipient address: \(error)") - return nil - } - } - - func decrypt(with viewer: PrivateKeyBundleV1) throws -> Data { - let header = try MessageHeaderV1(serializedData: headerBytes) - - let recipient = header.recipient - let sender = header.sender - - var secret: Data - if viewer.walletAddress == sender.walletAddress { - secret = try viewer.sharedSecret(peer: recipient, myPreKey: sender.preKey, isRecipient: false) - } else { - secret = try viewer.sharedSecret(peer: sender, myPreKey: recipient.preKey, isRecipient: true) - } - - return try Crypto.decrypt(secret, ciphertext, additionalData: headerBytes) - } -} diff --git a/Sources/XMTPiOS/Messages/MessageV2.swift b/Sources/XMTPiOS/Messages/MessageV2.swift deleted file mode 100644 index 3e5d0090..00000000 --- a/Sources/XMTPiOS/Messages/MessageV2.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// MessageV2.swift -// -// -// Created by Pat Nakajima on 12/5/22. -// - -import CryptoKit -import Foundation -import LibXMTP - -typealias MessageV2 = Xmtp_MessageContents_MessageV2 - -enum MessageV2Error: Error { - case invalidSignature, decodeError(String), invalidData -} - -extension MessageV2 { - init(headerBytes: Data, ciphertext: CipherText, senderHmac: Data, shouldPush: Bool) { - self.init() - self.headerBytes = headerBytes - self.ciphertext = ciphertext - self.senderHmac = senderHmac - self.shouldPush = shouldPush - } - - static func decrypt(_ id: String, _ topic: String, _ message: MessageV2, keyMaterial: Data, client: Client) throws -> DecryptedMessage { - let decrypted = try Crypto.decrypt(keyMaterial, message.ciphertext, additionalData: message.headerBytes) - let signed = try SignedContent(serializedData: decrypted) - - guard signed.sender.hasPreKey, signed.sender.hasIdentityKey else { - throw MessageV2Error.decodeError("missing sender pre-key or identity key") - } - - let senderPreKey = try PublicKey(signed.sender.preKey) - let senderIdentityKey = try PublicKey(signed.sender.identityKey) - - // This is a bit confusing since we're passing keyBytes as the digest instead of a SHA256 hash. - // That's because our underlying crypto library always SHA256's whatever data is sent to it for this. - if !(try senderPreKey.signature.verify(signedBy: senderIdentityKey, digest: signed.sender.preKey.keyBytes)) { - throw MessageV2Error.decodeError("pre-key not signed by identity key") - } - - // Verify content signature - let key = try PublicKey.with { key in - key.secp256K1Uncompressed.bytes = try KeyUtilx.recoverPublicKeySHA256(from: signed.signature.rawData, message: Data(message.headerBytes + signed.payload)) - } - - if key.walletAddress != (try PublicKey(signed.sender.preKey).walletAddress) { - throw MessageV2Error.invalidSignature - } - - let encodedMessage = try EncodedContent(serializedData: signed.payload) - let header = try MessageHeaderV2(serializedData: message.headerBytes) - - return DecryptedMessage( - id: id, - encodedContent: encodedMessage, - senderAddress: try signed.sender.walletAddress, - sentAt: Date(timeIntervalSince1970: Double(header.createdNs / 1_000_000) / 1000), - topic: topic - ) - } - - static func decode(_ id: String, _ topic: String, _ message: MessageV2, keyMaterial: Data, client: Client) throws -> DecodedMessage { - do { - let decryptedMessage = try decrypt(id, topic, message, keyMaterial: keyMaterial, client: client) - - return DecodedMessage( - id: id, - client: client, - topic: decryptedMessage.topic, - encodedContent: decryptedMessage.encodedContent, - senderAddress: decryptedMessage.senderAddress, - sent: decryptedMessage.sentAt - ) - } catch { - print("ERROR DECODING: \(error)") - throw error - } - } - - static func encode(client: Client, content encodedContent: EncodedContent, topic: String, keyMaterial: Data, codec: Codec) async throws -> MessageV2 { - let payload = try encodedContent.serializedData() - - let date = Date() - let header = MessageHeaderV2(topic: topic, created: date) - let headerBytes = try header.serializedData() - - let digest = SHA256.hash(data: headerBytes + payload) - let preKey = try client.keys.preKeys[0] - let signature = try await preKey.sign(Data(digest)) - - let bundle = try client.v1keys.toV2().getPublicKeyBundle() - - let signedContent = SignedContent(payload: payload, sender: bundle, signature: signature) - let signedBytes = try signedContent.serializedData() - - let ciphertext = try Crypto.encrypt(keyMaterial, signedBytes, additionalData: headerBytes) - - let thirtyDayPeriodsSinceEpoch = Int(date.timeIntervalSince1970 / 60 / 60 / 24 / 30) - let info = "\(thirtyDayPeriodsSinceEpoch)-\(client.address)" - guard let infoEncoded = info.data(using: .utf8) else { - throw MessageV2Error.invalidData - } - - let senderHmac = try Crypto.generateHmacSignature(secret: keyMaterial, info: infoEncoded, message: headerBytes) - - let decoded = try codec.decode(content: encodedContent, client: client) - let shouldPush = try codec.shouldPush(content: decoded) - - - return MessageV2( - headerBytes: headerBytes, - ciphertext: ciphertext, - senderHmac: senderHmac, - shouldPush: shouldPush - ) - } -} diff --git a/Sources/XMTPiOS/Messages/PagingInfo.swift b/Sources/XMTPiOS/Messages/PagingInfo.swift deleted file mode 100644 index 2f0f2eb9..00000000 --- a/Sources/XMTPiOS/Messages/PagingInfo.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// PagingInfo.swift -// -// -// Created by Pat Nakajima on 12/15/22. -// - -import Foundation - -typealias PagingInfo = Xmtp_MessageApi_V1_PagingInfo -typealias PagingInfoCursor = Xmtp_MessageApi_V1_Cursor -public typealias PagingInfoSortDirection = Xmtp_MessageApi_V1_SortDirection - -public struct Pagination { - public var limit: Int? - public var before: Date? - public var after: Date? - public var direction: PagingInfoSortDirection? - - public init(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) { - self.limit = limit - self.before = before - self.after = after - self.direction = direction - } - - var pagingInfo: PagingInfo { - var info = PagingInfo() - - if let limit { - info.limit = UInt32(limit) - } - info.direction = direction ?? Xmtp_MessageApi_V1_SortDirection.descending - return info - } -} - -extension PagingInfo { - init(limit: Int? = nil, cursor: PagingInfoCursor? = nil, direction: PagingInfoSortDirection? = nil) { - self.init() - - if let limit { - self.limit = UInt32(limit) - } - - if let cursor { - self.cursor = cursor - } - - if let direction { - self.direction = direction - } - } -} diff --git a/Sources/XMTPiOS/Messages/PrivateKey.swift b/Sources/XMTPiOS/Messages/PrivateKey.swift index 8d862dd3..53475b0d 100644 --- a/Sources/XMTPiOS/Messages/PrivateKey.swift +++ b/Sources/XMTPiOS/Messages/PrivateKey.swift @@ -1,13 +1,6 @@ -// -// PrivateKey.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - +import CryptoKit import Foundation import LibXMTP -import CryptoKit /// Represents a secp256k1 private key. ``PrivateKey`` conforms to ``SigningKey`` so you can use it /// to create a ``Client``. @@ -33,19 +26,12 @@ extension PrivateKey: SigningKey { walletAddress } - func matches(_ publicKey: PublicKey) -> Bool { - do { - return try self.publicKey.recoverKeySignedPublicKey() == (try publicKey.recoverKeySignedPublicKey()) - } catch { - return false - } - } - public func sign(_ data: Data) async throws -> Signature { - let signatureData = try KeyUtilx.sign(message: data, with: secp256K1.bytes, hashing: false) + let signatureData = try KeyUtilx.sign( + message: data, with: secp256K1.bytes, hashing: false) var signature = Signature() - signature.ecdsaCompact.bytes = signatureData[0 ..< 64] + signature.ecdsaCompact.bytes = signatureData[0..<64] signature.ecdsaCompact.recovery = UInt32(signatureData[64]) return signature @@ -58,9 +44,9 @@ extension PrivateKey: SigningKey { } } -public extension PrivateKey { +extension PrivateKey { // Easier conversion from the secp256k1 library's Private keys to our proto type. - init(_ privateKeyData: Data) throws { + public init(_ privateKeyData: Data) throws { self.init() timestamp = UInt64(Date().millisecondsSinceEpoch) secp256K1.bytes = privateKeyData @@ -70,14 +56,7 @@ public extension PrivateKey { publicKey.timestamp = timestamp } - init(_ signedPrivateKey: SignedPrivateKey) throws { - self.init() - timestamp = signedPrivateKey.createdNs / 1_000_000 - secp256K1.bytes = signedPrivateKey.secp256K1.bytes - publicKey = try PublicKey(signedPrivateKey.publicKey) - } - - static func generate() throws -> PrivateKey { + public static func generate() throws -> PrivateKey { let data = Data(try Crypto.secureRandomBytes(count: 32)) return try PrivateKey(data) } @@ -85,16 +64,4 @@ public extension PrivateKey { internal var walletAddress: String { publicKey.walletAddress } - - internal func sign(key: UnsignedPublicKey) async throws -> SignedPublicKey { - let bytes = try key.serializedData() - let digest = SHA256.hash(data: bytes) - let signature = try await sign(Data(digest)) - - var signedPublicKey = SignedPublicKey() - signedPublicKey.signature = signature - signedPublicKey.keyBytes = bytes - - return signedPublicKey - } } diff --git a/Sources/XMTPiOS/Messages/PrivateKeyBundle.swift b/Sources/XMTPiOS/Messages/PrivateKeyBundle.swift deleted file mode 100644 index dc67a5a2..00000000 --- a/Sources/XMTPiOS/Messages/PrivateKeyBundle.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// PrivateKeyBundle.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -public typealias PrivateKeyBundle = Xmtp_MessageContents_PrivateKeyBundle - -enum PrivateKeyBundleError: Error { - case noPreKeyFound -} - -extension PrivateKeyBundle { - init(v1: PrivateKeyBundleV1) { - self.init() - self.v1 = v1 - } - - func encrypted(with key: SigningKey, preEnableIdentityCallback: PreEventCallback? = nil) async throws -> EncryptedPrivateKeyBundle { - let bundleBytes = try serializedData() - let walletPreKey = try Crypto.secureRandomBytes(count: 32) - - try await preEnableIdentityCallback?() - - let signature = try await key.sign(message: Signature.enableIdentityText(key: walletPreKey)) - let cipherText = try Crypto.encrypt(signature.rawDataWithNormalizedRecovery, bundleBytes) - - var encryptedBundle = EncryptedPrivateKeyBundle() - encryptedBundle.v1.walletPreKey = walletPreKey - encryptedBundle.v1.ciphertext = cipherText - - return encryptedBundle - } -} diff --git a/Sources/XMTPiOS/Messages/PrivateKeyBundleV1.swift b/Sources/XMTPiOS/Messages/PrivateKeyBundleV1.swift deleted file mode 100644 index f717ed97..00000000 --- a/Sources/XMTPiOS/Messages/PrivateKeyBundleV1.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// PrivateKeyBundleV1.swift -// -// -// Created by Pat Nakajima on 11/22/22. -// - -import CryptoKit -import Foundation -import LibXMTP - -public typealias PrivateKeyBundleV1 = Xmtp_MessageContents_PrivateKeyBundleV1 - -extension PrivateKeyBundleV1 { - static func generate(wallet: SigningKey, options: ClientOptions? = nil) async throws -> PrivateKeyBundleV1 { - let privateKey = try PrivateKey.generate() - let authorizedIdentity = try await wallet.createIdentity(privateKey, preCreateIdentityCallback: options?.preCreateIdentityCallback) - - var bundle = try authorizedIdentity.toBundle - var preKey = try PrivateKey.generate() - - let bytesToSign = try UnsignedPublicKey(preKey.publicKey).serializedData() - let signature = try await privateKey.sign(Data(SHA256.hash(data: bytesToSign))) - - bundle.v1.identityKey = authorizedIdentity.identity - bundle.v1.identityKey.publicKey = authorizedIdentity.authorized - preKey.publicKey.signature = signature - - let signedPublicKey = try await privateKey.sign(key: UnsignedPublicKey(preKey.publicKey)) - - preKey.publicKey = try PublicKey(serializedData: signedPublicKey.keyBytes) - preKey.publicKey.signature = signedPublicKey.signature - bundle.v1.preKeys = [preKey] - - return bundle.v1 - } - - var walletAddress: String { - // swiftlint:disable no_optional_try - return (try? identityKey.publicKey.recoverWalletSignerPublicKey().walletAddress) ?? "" - // swiftlint:enable no_optional_try - } - - func toV2() -> PrivateKeyBundleV2 { - var v2bundle = PrivateKeyBundleV2() - - v2bundle.identityKey = SignedPrivateKey.fromLegacy(identityKey, signedByWallet: false) - v2bundle.preKeys = preKeys.map { SignedPrivateKey.fromLegacy($0) } - - return v2bundle - } - - func toPublicKeyBundle() -> PublicKeyBundle { - var publicKeyBundle = PublicKeyBundle() - - publicKeyBundle.identityKey = identityKey.publicKey - publicKeyBundle.preKey = preKeys[0].publicKey - - return publicKeyBundle - } - - func sharedSecret(peer: PublicKeyBundle, myPreKey: PublicKey, isRecipient: Bool) throws -> Data { - let peerBundle = try SignedPublicKeyBundle(peer) - let preKey = SignedPublicKey.fromLegacy(myPreKey) - - return try toV2().sharedSecret(peer: peerBundle, myPreKey: preKey, isRecipient: isRecipient) - } -} diff --git a/Sources/XMTPiOS/Messages/PrivateKeyBundleV2.swift b/Sources/XMTPiOS/Messages/PrivateKeyBundleV2.swift deleted file mode 100644 index bb7b04cb..00000000 --- a/Sources/XMTPiOS/Messages/PrivateKeyBundleV2.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// PrivateKeyBundleV2.swift -// -// -// Created by Pat Nakajima on 11/26/22. -// - -import Foundation -import LibXMTP - -public typealias PrivateKeyBundleV2 = Xmtp_MessageContents_PrivateKeyBundleV2 - -extension PrivateKeyBundleV2 { - func sharedSecret(peer: SignedPublicKeyBundle, myPreKey: SignedPublicKey, isRecipient: Bool) throws -> Data { - var dh1: Data - var dh2: Data - var preKey: SignedPrivateKey - - if isRecipient { - preKey = try findPreKey(myPreKey) - dh1 = try sharedSecret(private: preKey.secp256K1.bytes, public: peer.identityKey.secp256K1Uncompressed.bytes) - dh2 = try sharedSecret(private: identityKey.secp256K1.bytes, public: peer.preKey.secp256K1Uncompressed.bytes) - } else { - preKey = try findPreKey(myPreKey) - dh1 = try sharedSecret(private: identityKey.secp256K1.bytes, public: peer.preKey.secp256K1Uncompressed.bytes) - dh2 = try sharedSecret(private: preKey.secp256K1.bytes, public: peer.identityKey.secp256K1Uncompressed.bytes) - } - - let dh3 = try sharedSecret(private: preKey.secp256K1.bytes, public: peer.preKey.secp256K1Uncompressed.bytes) - - let secret = dh1 + dh2 + dh3 - - return secret - } - - func sharedSecret(private privateData: Data, public publicData: Data) throws -> Data { - return Data(try LibXMTP.diffieHellmanK256(privateKeyBytes: privateData, publicKeyBytes: publicData)) - } - - func findPreKey(_ myPreKey: SignedPublicKey) throws -> SignedPrivateKey { - for preKey in preKeys { - if preKey.matches(myPreKey) { - return preKey - } - } - - throw PrivateKeyBundleError.noPreKeyFound - } - - func toV1() throws -> PrivateKeyBundleV1 { - var bundle = PrivateKeyBundleV1() - bundle.identityKey = try PrivateKey(identityKey) - bundle.preKeys = try preKeys.map { try PrivateKey($0) } - return bundle - } - - func getPublicKeyBundle() -> SignedPublicKeyBundle { - var publicKeyBundle = SignedPublicKeyBundle() - - publicKeyBundle.identityKey = identityKey.publicKey - publicKeyBundle.identityKey.signature = identityKey.publicKey.signature - publicKeyBundle.identityKey.signature.ensureWalletSignature() - publicKeyBundle.preKey = preKeys[0].publicKey - - return publicKeyBundle - } -} diff --git a/Sources/XMTPiOS/Messages/PublicKey.swift b/Sources/XMTPiOS/Messages/PublicKey.swift index a465668b..f83ddc7e 100644 --- a/Sources/XMTPiOS/Messages/PublicKey.swift +++ b/Sources/XMTPiOS/Messages/PublicKey.swift @@ -1,15 +1,7 @@ -// -// PublicKey.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - +import CryptoKit import Foundation - import LibXMTP import web3 -import CryptoKit typealias PublicKey = Xmtp_MessageContents_PublicKey @@ -18,78 +10,8 @@ enum PublicKeyError: String, Error { } extension PublicKey { - init(_ signedPublicKey: SignedPublicKey) throws { - self.init() - - let unsignedPublicKey = try PublicKey(serializedData: signedPublicKey.keyBytes) - - timestamp = unsignedPublicKey.timestamp - secp256K1Uncompressed.bytes = unsignedPublicKey.secp256K1Uncompressed.bytes - var signature = signedPublicKey.signature - - if !signature.walletEcdsaCompact.bytes.isEmpty { - signature.ecdsaCompact.bytes = signedPublicKey.signature.walletEcdsaCompact.bytes - signature.ecdsaCompact.recovery = signedPublicKey.signature.walletEcdsaCompact.recovery - } - - self.signature = signature - } - - init(_ unsignedPublicKey: UnsignedPublicKey) { - self.init() - secp256K1Uncompressed.bytes = unsignedPublicKey.secp256K1Uncompressed.bytes - timestamp = unsignedPublicKey.createdNs / 1_000_000 - } - - init(_ data: Data) throws { - self.init() - - timestamp = UInt64(Date().millisecondsSinceEpoch) - secp256K1Uncompressed.bytes = data - } - - init(_ string: String) throws { - self.init() - - guard let bytes = string.web3.bytesFromHex else { - throw PublicKeyError.invalidKeyString - } - - try self.init(Data(bytes)) - } - - func recoverWalletSignerPublicKey() throws -> PublicKey { - if !hasSignature { - throw PublicKeyError.noSignature - } - - var slimKey = PublicKey() - slimKey.timestamp = timestamp - slimKey.secp256K1Uncompressed.bytes = secp256K1Uncompressed.bytes - - let sigText = Signature.createIdentityText(key: try slimKey.serializedData()) - let message = try Signature.ethPersonalMessage(sigText) - - let pubKeyData = try KeyUtilx.recoverPublicKeyKeccak256(from: signature.rawData, message: message) - return try PublicKey(pubKeyData) - } - - func recoverKeySignedPublicKey() throws -> PublicKey { - if !hasSignature { - throw PublicKeyError.noSignature - } - - // We don't want to include the signature in the key bytes - var slimKey = PublicKey() - slimKey.secp256K1Uncompressed.bytes = secp256K1Uncompressed.bytes - slimKey.timestamp = timestamp - let bytesToSign = try slimKey.serializedData() - - let pubKeyData = try KeyUtilx.recoverPublicKeySHA256(from: signature.rawData, message: bytesToSign) - return try PublicKey(pubKeyData) - } - var walletAddress: String { - KeyUtilx.generateAddress(from: secp256K1Uncompressed.bytes).toChecksumAddress() + KeyUtilx.generateAddress(from: secp256K1Uncompressed.bytes) + .toChecksumAddress() } } diff --git a/Sources/XMTPiOS/Messages/PublicKeyBundle.swift b/Sources/XMTPiOS/Messages/PublicKeyBundle.swift deleted file mode 100644 index 0945db63..00000000 --- a/Sources/XMTPiOS/Messages/PublicKeyBundle.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// PublicKeyBundle.swift -// -// -// Created by Pat Nakajima on 11/23/22. -// - -typealias PublicKeyBundle = Xmtp_MessageContents_PublicKeyBundle - -extension PublicKeyBundle { - init(_ signedPublicKeyBundle: SignedPublicKeyBundle) throws { - self.init() - - identityKey = try PublicKey(signedPublicKeyBundle.identityKey) - preKey = try PublicKey(signedPublicKeyBundle.preKey) - } - - var walletAddress: String { - // swiftlint:disable no_optional_try - return (try? identityKey.recoverWalletSignerPublicKey().walletAddress) ?? "" - // swiftlint:enable no_optional_try - } -} diff --git a/Sources/XMTPiOS/Messages/SealedInvitation.swift b/Sources/XMTPiOS/Messages/SealedInvitation.swift deleted file mode 100644 index 920ee2fc..00000000 --- a/Sources/XMTPiOS/Messages/SealedInvitation.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// SealedInvitation.swift -// -// -// Created by Pat Nakajima on 11/26/22. -// - -import Foundation - - -typealias SealedInvitation = Xmtp_MessageContents_SealedInvitation - -enum SealedInvitationError: Error, CustomStringConvertible { - case noSignature - - var description: String { - "SealedInvitationError.noSignature" - } -} - -extension SealedInvitation { - static func createV1(sender: PrivateKeyBundleV2, recipient: SignedPublicKeyBundle, created: Date, invitation: InvitationV1) throws -> SealedInvitation { - let header = SealedInvitationHeaderV1( - sender: sender.getPublicKeyBundle(), - recipient: recipient, - createdNs: UInt64(created.millisecondsSinceEpoch * 1_000_000) - ) - - let secret = try sender.sharedSecret(peer: recipient, myPreKey: sender.preKeys[0].publicKey, isRecipient: false) - - let headerBytes = try header.serializedData() - let invitationBytes = try invitation.serializedData() - - let ciphertext = try Crypto.encrypt(secret, invitationBytes, additionalData: headerBytes) - - return SealedInvitation(headerBytes: headerBytes, ciphertext: ciphertext) - } - - init(headerBytes: Data, ciphertext: CipherText) { - self.init() - v1.headerBytes = headerBytes - v1.ciphertext = ciphertext - } - - func involves(_ contact: ContactBundle) -> Bool { - do { - let contactSignedPublicKeyBundle = try contact.toSignedPublicKeyBundle() - let walletAddress = try contactSignedPublicKeyBundle.walletAddress - return try v1.header.recipient.walletAddress == walletAddress || v1.header.sender.walletAddress == walletAddress - } catch { - return false - } - } -} diff --git a/Sources/XMTPiOS/Messages/SealedInvitationHeaderV1.swift b/Sources/XMTPiOS/Messages/SealedInvitationHeaderV1.swift deleted file mode 100644 index 70bbafc2..00000000 --- a/Sources/XMTPiOS/Messages/SealedInvitationHeaderV1.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// SealedInvitationHeaderV1.swift -// -// -// Created by Pat Nakajima on 11/26/22. -// - -import Foundation - - -public typealias SealedInvitationHeaderV1 = Xmtp_MessageContents_SealedInvitationHeaderV1 - -extension SealedInvitationHeaderV1 { - init(sender: SignedPublicKeyBundle, recipient: SignedPublicKeyBundle, createdNs: UInt64) { - self.init() - self.sender = sender - self.recipient = recipient - self.createdNs = createdNs - } -} - -extension SealedInvitationHeaderV1: Codable { - enum CodingKeys: CodingKey { - case sender, recipient, createdNs - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(sender, forKey: .sender) - try container.encode(recipient, forKey: .recipient) - try container.encode(createdNs, forKey: .createdNs) - } - - public init(from decoder: Decoder) throws { - self.init() - - let container = try decoder.container(keyedBy: CodingKeys.self) - sender = try container.decode(SignedPublicKeyBundle.self, forKey: .sender) - recipient = try container.decode(SignedPublicKeyBundle.self, forKey: .recipient) - createdNs = try container.decode(UInt64.self, forKey: .createdNs) - } -} diff --git a/Sources/XMTPiOS/Messages/SealedInvitationV1.swift b/Sources/XMTPiOS/Messages/SealedInvitationV1.swift deleted file mode 100644 index b1376300..00000000 --- a/Sources/XMTPiOS/Messages/SealedInvitationV1.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// SealedInvitationV1.swift -// -// -// Created by Pat Nakajima on 11/26/22. -// - -import Foundation - -typealias SealedInvitationV1 = Xmtp_MessageContents_SealedInvitationV1 - -extension SealedInvitationV1 { - init(headerBytes: Data, ciphtertext: CipherText, header _: SealedInvitationHeaderV1? = nil) { - self.init() - self.headerBytes = headerBytes - ciphertext = ciphtertext - } - - var header: SealedInvitationHeaderV1 { - do { - return try SealedInvitationHeaderV1(serializedData: headerBytes) - } catch { - return SealedInvitationHeaderV1() - } - } - - func getInvitation(viewer: PrivateKeyBundleV2) throws -> InvitationV1 { - let header = header - - var secret: Data - - if !header.sender.identityKey.hasSignature { - throw SealedInvitationError.noSignature - } - - if viewer.identityKey.matches(header.sender.identityKey) { - secret = try viewer.sharedSecret(peer: header.recipient, myPreKey: header.sender.preKey, isRecipient: false) - } else { - secret = try viewer.sharedSecret(peer: header.sender, myPreKey: header.recipient.preKey, isRecipient: true) - } - - let decryptedBytes = try Crypto.decrypt(secret, ciphertext, additionalData: headerBytes) - let invitation = try InvitationV1(serializedData: decryptedBytes) - - return invitation - } -} diff --git a/Sources/XMTPiOS/Messages/SignedContent.swift b/Sources/XMTPiOS/Messages/SignedContent.swift deleted file mode 100644 index 682cea07..00000000 --- a/Sources/XMTPiOS/Messages/SignedContent.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// SignedContent.swift -// -// -// Created by Pat Nakajima on 12/5/22. -// - -import Foundation - -typealias SignedContent = Xmtp_MessageContents_SignedContent - -extension SignedContent { - init(payload: Data, sender: SignedPublicKeyBundle, signature: Signature) { - self.init() - self.payload = payload - self.sender = sender - self.signature = signature - } -} diff --git a/Sources/XMTPiOS/Messages/SignedPrivateKey.swift b/Sources/XMTPiOS/Messages/SignedPrivateKey.swift deleted file mode 100644 index 17db7255..00000000 --- a/Sources/XMTPiOS/Messages/SignedPrivateKey.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// SignedPrivateKey.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -public typealias SignedPrivateKey = Xmtp_MessageContents_SignedPrivateKey - -extension SignedPrivateKey { - static func fromLegacy(_ key: PrivateKey, signedByWallet: Bool? = false) -> SignedPrivateKey { - var signedPrivateKey = SignedPrivateKey() - - signedPrivateKey.createdNs = key.timestamp * 1_000_000 - signedPrivateKey.secp256K1.bytes = key.secp256K1.bytes - signedPrivateKey.publicKey = SignedPublicKey.fromLegacy(key.publicKey, signedByWallet: signedByWallet) - signedPrivateKey.publicKey.signature = key.publicKey.signature - - return signedPrivateKey - } - - public func sign(_ data: Data) async throws -> Signature { - let key = try PrivateKey(secp256K1.bytes) - return try await key.sign(data) - } - - func matches(_ signedPublicKey: SignedPublicKey) -> Bool { - do { - return try publicKey.recoverKeySignedPublicKey().walletAddress == - (try signedPublicKey.recoverKeySignedPublicKey().walletAddress) - } catch { - return false - } - } -} diff --git a/Sources/XMTPiOS/Messages/SignedPublicKey.swift b/Sources/XMTPiOS/Messages/SignedPublicKey.swift deleted file mode 100644 index bd0581f9..00000000 --- a/Sources/XMTPiOS/Messages/SignedPublicKey.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// SignedPublicKey.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import CryptoKit -import Foundation - -import LibXMTP -import web3 - -typealias SignedPublicKey = Xmtp_MessageContents_SignedPublicKey - -extension SignedPublicKey { - static func fromLegacy(_ legacyKey: PublicKey, signedByWallet _: Bool? = false) -> SignedPublicKey { - var signedPublicKey = SignedPublicKey() - - var publicKey = PublicKey() - publicKey.secp256K1Uncompressed = legacyKey.secp256K1Uncompressed - publicKey.timestamp = legacyKey.timestamp - - // swiftlint:disable force_try - signedPublicKey.keyBytes = try! publicKey.serializedData() - // swiftlint:enable force_try - signedPublicKey.signature = legacyKey.signature - - return signedPublicKey - } - - init(_ publicKey: PublicKey, signature: Signature) throws { - self.init() - self.signature = signature - - var unsignedKey = PublicKey() - unsignedKey.timestamp = publicKey.timestamp - unsignedKey.secp256K1Uncompressed.bytes = publicKey.secp256K1Uncompressed.bytes - - keyBytes = try unsignedKey.serializedData() - } - - var secp256K1Uncompressed: PublicKey.Secp256k1Uncompressed { - // swiftlint:disable force_try - let key = try! PublicKey(serializedData: keyBytes) - // swiftlint:enable force_try - return key.secp256K1Uncompressed - } - - func verify(key: SignedPublicKey) throws -> Bool { - if !key.hasSignature { - return false - } - - return try signature.verify(signedBy: try PublicKey(key), digest: key.keyBytes) - } - - func recoverKeySignedPublicKey() throws -> PublicKey { - let publicKey = try PublicKey(self) - - // We don't want to include the signature in the key bytes - var slimKey = PublicKey() - slimKey.secp256K1Uncompressed.bytes = secp256K1Uncompressed.bytes - slimKey.timestamp = publicKey.timestamp - let bytesToSign = try slimKey.serializedData() - - let pubKeyData = try KeyUtilx.recoverPublicKeySHA256(from: publicKey.signature.rawData, message: bytesToSign) - return try PublicKey(pubKeyData) - } - - func recoverWalletSignerPublicKey() throws -> PublicKey { - let sigText = Signature.createIdentityText(key: keyBytes) - let message = try Signature.ethPersonalMessage(sigText) - - let pubKeyData = try KeyUtilx.recoverPublicKeyKeccak256(from: signature.rawData, message: message) - - return try PublicKey(pubKeyData) - } -} - -extension SignedPublicKey: Codable { - enum CodingKeys: CodingKey { - case keyBytes, signature - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(keyBytes, forKey: .keyBytes) - try container.encode(signature, forKey: .signature) - } - - public init(from decoder: Decoder) throws { - self.init() - - let container = try decoder.container(keyedBy: CodingKeys.self) - - keyBytes = try container.decode(Data.self, forKey: .keyBytes) - signature = try container.decode(Signature.self, forKey: .signature) - } -} diff --git a/Sources/XMTPiOS/Messages/SignedPublicKeyBundle.swift b/Sources/XMTPiOS/Messages/SignedPublicKeyBundle.swift deleted file mode 100644 index 4b661697..00000000 --- a/Sources/XMTPiOS/Messages/SignedPublicKeyBundle.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// SignedPublicKeyBundle.swift -// -// -// Created by Pat Nakajima on 11/23/22. -// - -public typealias SignedPublicKeyBundle = Xmtp_MessageContents_SignedPublicKeyBundle - -extension SignedPublicKeyBundle { - init(_ publicKeyBundle: PublicKeyBundle) throws { - self.init() - - identityKey = SignedPublicKey.fromLegacy(publicKeyBundle.identityKey) - identityKey.signature = publicKeyBundle.identityKey.signature - preKey = SignedPublicKey.fromLegacy(publicKeyBundle.preKey) - preKey.signature = publicKeyBundle.preKey.signature - } - - func equals(_ other: SignedPublicKeyBundle) -> Bool { - return identityKey == other.identityKey && preKey == other.preKey - } - - var walletAddress: String { - get throws { - return try identityKey.recoverWalletSignerPublicKey().walletAddress - } - } -} - -extension SignedPublicKeyBundle: Codable { - enum CodingKeys: CodingKey { - case identityKey, preKey - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(identityKey, forKey: .identityKey) - try container.encode(preKey, forKey: .preKey) - } - - public init(from decoder: Decoder) throws { - self.init() - - let container = try decoder.container(keyedBy: CodingKeys.self) - identityKey = try container.decode(SignedPublicKey.self, forKey: .identityKey) - preKey = try container.decode(SignedPublicKey.self, forKey: .preKey) - } -} diff --git a/Sources/XMTPiOS/Messages/Token.swift b/Sources/XMTPiOS/Messages/Token.swift deleted file mode 100644 index ec95aaa1..00000000 --- a/Sources/XMTPiOS/Messages/Token.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Token.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -typealias Token = Xmtp_MessageApi_V1_Token diff --git a/Sources/XMTPiOS/Messages/Topic.swift b/Sources/XMTPiOS/Messages/Topic.swift index f102f1ca..e34ce96f 100644 --- a/Sources/XMTPiOS/Messages/Topic.swift +++ b/Sources/XMTPiOS/Messages/Topic.swift @@ -6,43 +6,17 @@ // public enum Topic { - case userPrivateStoreKeyBundle(String), - contact(String), - userIntro(String), - userInvite(String), - directMessageV1(String, String), - directMessageV2(String), - preferenceList(String), - userWelcome(String), + case userWelcome(String), groupMessage(String) var description: String { switch self { - case let .userPrivateStoreKeyBundle(address): - return wrap("privatestore-\(address)/key_bundle") - case let .contact(address): - return wrap("contact-\(address)") - case let .userIntro(address): - return wrap("intro-\(address)") - case let .userInvite(address): - return wrap("invite-\(address)") - case let .directMessageV1(address1, address2): - let addresses = [address1, address2].sorted().joined(separator: "-") - return wrap("dm-\(addresses)") - case let .directMessageV2(randomString): - return wrap("m-\(randomString)") - case let .preferenceList(identifier): - return wrap("userpreferences-\(identifier)") case let .groupMessage(groupId): return wrapMls("g-\(groupId)") case let .userWelcome(installationId): return wrapMls("w-\(installationId)") } } - - private func wrap(_ value: String) -> String { - "/xmtp/0/\(value)/proto" - } private func wrapMls(_ value: String) -> String { "/xmtp/mls/1/\(value)/proto" diff --git a/Sources/XMTPiOS/Messages/UnsignedPublicKey.swift b/Sources/XMTPiOS/Messages/UnsignedPublicKey.swift deleted file mode 100644 index 17fa3ee6..00000000 --- a/Sources/XMTPiOS/Messages/UnsignedPublicKey.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// UnsignedPublicKey.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -typealias UnsignedPublicKey = Xmtp_MessageContents_UnsignedPublicKey - -extension UnsignedPublicKey { - static func generate() throws -> UnsignedPublicKey { - var unsigned = UnsignedPublicKey() - let key = try PrivateKey.generate() - let createdNs = Date().millisecondsSinceEpoch - unsigned.secp256K1Uncompressed.bytes = key.publicKey.secp256K1Uncompressed.bytes - unsigned.createdNs = UInt64(createdNs) - return unsigned - } - - init(_ publicKey: PublicKey) { - self.init() - - createdNs = publicKey.timestamp - secp256K1Uncompressed.bytes = publicKey.secp256K1Uncompressed.bytes - } -} diff --git a/Sources/XMTPiOS/Mls/MessageV3.swift b/Sources/XMTPiOS/Mls/MessageV3.swift deleted file mode 100644 index 31301854..00000000 --- a/Sources/XMTPiOS/Mls/MessageV3.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// MessageV3.swift -// -// -// Created by Naomi Plasterer on 4/10/24. -// - -import Foundation -import LibXMTP - -enum MessageV3Error: Error { - case decodeError(String) -} - -public struct MessageV3: Identifiable { - let client: Client - let ffiMessage: FfiMessage - - init(client: Client, ffiMessage: FfiMessage) { - self.client = client - self.ffiMessage = ffiMessage - } - - public var id: String { - return ffiMessage.id.toHex - } - - var convoId: String { - return ffiMessage.convoId.toHex - } - - var senderInboxId: String { - return ffiMessage.senderInboxId - } - - var sentAt: Date { - return Date(timeIntervalSince1970: TimeInterval(ffiMessage.sentAtNs) / 1_000_000_000) - } - - var deliveryStatus: MessageDeliveryStatus { - switch ffiMessage.deliveryStatus { - case .unpublished: - return .unpublished - case .published: - return .published - case .failed: - return .failed - } - } - - public func decode() throws -> DecodedMessage { - do { - let encodedContent = try EncodedContent(serializedData: ffiMessage.content) - - let decodedMessage = DecodedMessage( - id: id, - client: client, - topic: Topic.groupMessage(convoId).description, - encodedContent: encodedContent, - senderAddress: senderInboxId, - sent: sentAt, - deliveryStatus: deliveryStatus - ) - - if decodedMessage.encodedContent.type == ContentTypeGroupUpdated && ffiMessage.kind != .membershipChange { - throw MessageV3Error.decodeError("Error decoding group membership change") - } - - return decodedMessage - } catch { - throw MessageV3Error.decodeError("Error decoding message: \(error.localizedDescription)") - } - } - - public func decodeOrNull() -> DecodedMessage? { - do { - return try decode() - } catch { - print("MESSAGE_V3: discarding message that failed to decode", error) - return nil - } - } - - public func decryptOrNull() -> DecryptedMessage? { - do { - return try decrypt() - } catch { - print("MESSAGE_V3: discarding message that failed to decrypt", error) - return nil - } - } - - public func decrypt() throws -> DecryptedMessage { - let encodedContent = try EncodedContent(serializedData: ffiMessage.content) - - let decrytedMessage = DecryptedMessage( - id: id, - encodedContent: encodedContent, - senderAddress: senderInboxId, - sentAt: sentAt, - topic: Topic.groupMessage(convoId).description, - deliveryStatus: deliveryStatus - ) - - if decrytedMessage.encodedContent.type == ContentTypeGroupUpdated && ffiMessage.kind != .membershipChange { - throw MessageV3Error.decodeError("Error decoding group membership change") - } - - return decrytedMessage - } -} diff --git a/Sources/XMTPiOS/PreparedMessage.swift b/Sources/XMTPiOS/PreparedMessage.swift deleted file mode 100644 index 721d0bfd..00000000 --- a/Sources/XMTPiOS/PreparedMessage.swift +++ /dev/null @@ -1,37 +0,0 @@ -import CryptoKit -import Foundation - -// This houses a fully prepared message that can be published -// as soon as the API client has connectivity. -// -// To support persistance layers that queue pending messages (e.g. while offline) -// this struct supports serializing to/from bytes that can be written to disk or elsewhere. -// See serializedData() and fromSerializedData() -public struct PreparedMessage { - - // The first envelope should send the message to the conversation itself. - // Any more are for required intros/invites etc. - // A client can just publish these when it has connectivity. - public let envelopes: [Envelope] - public let encodedContent: EncodedContent? - - // Note: we serialize as a PublishRequest as a convenient `envelopes` wrapper. - public static func fromSerializedData(_ serializedData: Data) throws -> PreparedMessage { - let req = try Xmtp_MessageApi_V1_PublishRequest(serializedData: serializedData) - return PreparedMessage(envelopes: req.envelopes, encodedContent: nil) - } - - // Note: we serialize as a PublishRequest as a convenient `envelopes` wrapper. - public func serializedData() throws -> Data { - let req = Xmtp_MessageApi_V1_PublishRequest.with { $0.envelopes = envelopes } - return try req.serializedData() - } - - public var messageID: String { - Data(SHA256.hash(data: envelopes[0].message)).toHex - } - - public var conversationTopic: String { - envelopes[0].contentTopic - } -} diff --git a/Sources/XMTPiOS/PrivatePreferences.swift b/Sources/XMTPiOS/PrivatePreferences.swift new file mode 100644 index 00000000..c8980f3d --- /dev/null +++ b/Sources/XMTPiOS/PrivatePreferences.swift @@ -0,0 +1,105 @@ +import Foundation +import LibXMTP + +public enum ConsentState: String, Codable { + case allowed, denied, unknown +} +public enum EntryType: String, Codable { + case address, conversation_id, inbox_id +} + +public struct ConsentListEntry: Codable, Hashable { + static func address(_ address: String, type: ConsentState = .unknown) + -> ConsentListEntry + { + ConsentListEntry(value: address, entryType: .address, consentType: type) + } + + static func conversationId( + conversationId: String, type: ConsentState = ConsentState.unknown + ) -> ConsentListEntry { + ConsentListEntry( + value: conversationId, entryType: .conversation_id, consentType: type) + } + + static func inboxId(_ inboxId: String, type: ConsentState = .unknown) + -> ConsentListEntry + { + ConsentListEntry( + value: inboxId, entryType: .inbox_id, consentType: type) + } + + public var value: String + public var entryType: EntryType + public var consentType: ConsentState + + var key: String { + "\(entryType)-\(value)" + } +} + +public enum ContactError: Error { + case invalidIdentifier +} + +public actor EntriesManager { + public var map: [String: ConsentListEntry] = [:] + + func set(_ key: String, _ object: ConsentListEntry) { + map[key] = object + } + + func get(_ key: String) -> ConsentListEntry? { + map[key] + } +} + +public class ConsentList { + public let entriesManager = EntriesManager() + var lastFetched: Date? + var client: Client + var ffiClient: FfiXmtpClient + + init(client: Client, ffiClient: FfiXmtpClient) { + self.client = client + self.ffiClient = ffiClient + } + + func setConsentState(entries: [ConsentListEntry]) async throws { + try await ffiClient.setConsentStates(records: entries.map(\.toFFI)) + } + + func addressState(address: String) async throws -> ConsentState { + return try await ffiClient.getConsentState( + entityType: .address, + entity: address + ).fromFFI + } + + func conversationState(conversationId: String) async throws -> ConsentState { + return try await ffiClient.getConsentState( + entityType: .conversationId, + entity: conversationId + ).fromFFI + } + + func inboxIdState(inboxId: String) async throws -> ConsentState { + return try await ffiClient.getConsentState( + entityType: .inboxId, + entity: inboxId + ).fromFFI + } +} + +/// Provides access to contact bundles. +public actor PrivatePreferences { + var client: Client + var ffiClient: FfiXmtpClient + public var consentList: ConsentList + + init(client: Client, ffiClient: FfiXmtpClient) { + self.client = client + self.ffiClient = ffiClient + consentList = ConsentList(client: client, ffiClient: ffiClient) + } +} diff --git a/Sources/XMTPiOS/SigningKey.swift b/Sources/XMTPiOS/SigningKey.swift index 22a3fda9..ddfc2ec9 100644 --- a/Sources/XMTPiOS/SigningKey.swift +++ b/Sources/XMTPiOS/SigningKey.swift @@ -56,28 +56,6 @@ extension SigningKey { public var blockNumber: Int64? { return nil } - - func createIdentity(_ identity: PrivateKey, preCreateIdentityCallback: PreEventCallback? = nil) async throws -> AuthorizedIdentity { - var slimKey = PublicKey() - slimKey.timestamp = UInt64(Date().millisecondsSinceEpoch) - slimKey.secp256K1Uncompressed = identity.publicKey.secp256K1Uncompressed - - try await preCreateIdentityCallback?() - - let signatureText = Signature.createIdentityText(key: try slimKey.serializedData()) - let signature = try await sign(message: signatureText) - - let message = try Signature.ethPersonalMessage(signatureText) - let recoveredKey = try KeyUtilx.recoverPublicKeyKeccak256(from: signature.rawData, message: message) - let address = KeyUtilx.generateAddress(from: recoveredKey).toChecksumAddress() - - var authorized = PublicKey() - authorized.secp256K1Uncompressed = slimKey.secp256K1Uncompressed - authorized.timestamp = slimKey.timestamp - authorized.signature = signature - - return AuthorizedIdentity(address: address, authorized: authorized, identity: identity) - } public func sign(_ data: Data) async throws -> Signature { throw NSError(domain: "NotImplemented", code: 1, userInfo: [NSLocalizedDescriptionKey: "sign(Data) not implemented."]) diff --git a/Tests/XMTPTests/AttachmentTests.swift b/Tests/XMTPTests/AttachmentTests.swift index f551afe9..c720ec30 100644 --- a/Tests/XMTPTests/AttachmentTests.swift +++ b/Tests/XMTPTests/AttachmentTests.swift @@ -1,28 +1,29 @@ -// -// AttachmentsTests.swift -// -// -// Created by Pat on 2/14/23. -// import Foundation - import XCTest + @testable import XMTPiOS @available(iOS 15, *) class AttachmentsTests: XCTestCase { func testCanUseAttachmentCodec() async throws { // swiftlint:disable force_try - let iconData = Data(base64Encoded: Data("iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAACymlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj43MjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+NzI8L3RpZmY6WFJlc29sdXRpb24+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj40NjA8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjQ2MDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgr0TTmKAAAC5ElEQVQ4EW2TWWxMYRTHf3e2zjClVa0trVoqFRk1VKmIWhJ0JmkNETvvEtIHLwixxoM1xIOIiAjzxhBCQ9ESlRJNJEj7gJraJ63SdDrbvc53Z6xx7r253/lyzvnO/3/+n7a69KTBnyae1anJZ0nviq9pkIzppKLK+TMYbH+74Bhsobslzmv6yJQgJUHFuMiryCL+Tf8r5XcBqWxzWWhv+c6cDSPYsm4ehWPy5XSNd28j3Aw+49apMOO92aT6pRN5lf0qoJI7nvay4/JcFi+ZTiKepLPjC4ahM3VGCZVVk6iqaWWv/w5F3gEkFRyzgPxV221y8s5L6eSbocdUB25QhFUeBE6C0MWF1K6aReqqzs6aBkorBhHv0bEpwr4K5tlrhrM4MJ36K084HXhEfcjH/WvtJBM685dO5MymRyacmpWVNKx7Sdv5LrLL7FhU64ow//rJxGMJTix5QP4CF/P9Xjbv81F3wM8CWQ/1uDixqpn+aJzqtR5eSY6alMUQCIrXwuJ8PrzrokfaDTf0cnhbiPxhOQwbkcvBrZd5e/07SYl83xmhaGyBgm/az0ll3DQxulCc5fzFr7nuIs5Dotjtsm8emo61KZEobXS+iTCzaiJuGUxJTQ51u2t5H46QTKao21NL9+cgG6cNl04LCJ6+xxDsGCkDqyfPt2vgJyvdWg+LlgvWMhvNFzpwF2sEjzdzO/iCyurx+FaU45k2hicP2zgSaGLUFBlln4FNiSKnwkHT+Y/UL31sTkLXDdHCdSbIKVHp90PBWRbuH0dPJMrdo2EKSp3osQwE1b+SZ4nXzYFAI1pIw7esgv5+b0ZIBucONXJ2+3NG4mTk1AFyJ4QlxbzkWj1D/bsUg7oIfkihg0vH2nkVfoM7105untsk7UVrmL7WGLnlWSR6M3dBESem/XsbHYMsdLXERBtRU4UqaFz2QJyjbRgJaTuTqPaV/Z5V2jflObjMQbnLKW2mcSaErP8lq5QfTHkZ9teKBsUAAAAASUVORK5CYII=".utf8))! - let fixtures = await fixtures() - let conversation = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) + let iconData = Data( + base64Encoded: Data( + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAACymlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj43MjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+NzI8L3RpZmY6WFJlc29sdXRpb24+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj40NjA8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjQ2MDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgr0TTmKAAAC5ElEQVQ4EW2TWWxMYRTHf3e2zjClVa0trVoqFRk1VKmIWhJ0JmkNETvvEtIHLwixxoM1xIOIiAjzxhBCQ9ESlRJNJEj7gJraJ63SdDrbvc53Z6xx7r253/lyzvnO/3/+n7a69KTBnyae1anJZ0nviq9pkIzppKLK+TMYbH+74Bhsobslzmv6yJQgJUHFuMiryCL+Tf8r5XcBqWxzWWhv+c6cDSPYsm4ehWPy5XSNd28j3Aw+49apMOO92aT6pRN5lf0qoJI7nvay4/JcFi+ZTiKepLPjC4ahM3VGCZVVk6iqaWWv/w5F3gEkFRyzgPxV221y8s5L6eSbocdUB25QhFUeBE6C0MWF1K6aReqqzs6aBkorBhHv0bEpwr4K5tlrhrM4MJ36K084HXhEfcjH/WvtJBM685dO5MymRyacmpWVNKx7Sdv5LrLL7FhU64ow//rJxGMJTix5QP4CF/P9Xjbv81F3wM8CWQ/1uDixqpn+aJzqtR5eSY6alMUQCIrXwuJ8PrzrokfaDTf0cnhbiPxhOQwbkcvBrZd5e/07SYl83xmhaGyBgm/az0ll3DQxulCc5fzFr7nuIs5Dotjtsm8emo61KZEobXS+iTCzaiJuGUxJTQ51u2t5H46QTKao21NL9+cgG6cNl04LCJ6+xxDsGCkDqyfPt2vgJyvdWg+LlgvWMhvNFzpwF2sEjzdzO/iCyurx+FaU45k2hicP2zgSaGLUFBlln4FNiSKnwkHT+Y/UL31sTkLXDdHCdSbIKVHp90PBWRbuH0dPJMrdo2EKSp3osQwE1b+SZ4nXzYFAI1pIw7esgv5+b0ZIBucONXJ2+3NG4mTk1AFyJ4QlxbzkWj1D/bsUg7oIfkihg0vH2nkVfoM7105untsk7UVrmL7WGLnlWSR6M3dBESem/XsbHYMsdLXERBtRU4UqaFz2QJyjbRgJaTuTqPaV/Z5V2jflObjMQbnLKW2mcSaErP8lq5QfTHkZ9teKBsUAAAAASUVORK5CYII=" + .utf8))! + let fixtures = try await fixtures() + let conversation = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) - fixtures.aliceClient.register(codec: AttachmentCodec()) + fixtures.alixClient.register(codec: AttachmentCodec()) - try await conversation.send(content: Attachment(filename: "icon.png", mimeType: "image/png", data: iconData), options: .init(contentType: ContentTypeAttachment)) + try await conversation.send( + content: Attachment( + filename: "icon.png", mimeType: "image/png", data: iconData), + options: .init(contentType: ContentTypeAttachment)) let messages = try await conversation.messages() - XCTAssertEqual(1, messages.count) + XCTAssertEqual(2, messages.count) let message = messages[0] let attachment: Attachment = try message.content() diff --git a/Tests/XMTPTests/AuthenticationTests.swift b/Tests/XMTPTests/AuthenticationTests.swift deleted file mode 100644 index ad1568ba..00000000 --- a/Tests/XMTPTests/AuthenticationTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// AuthenticationTests.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation - -import XCTest -@testable import XMTPiOS - -final class AuthenticationTests: XCTestCase { - func testCreateToken() async throws { - let key = try PrivateKey.generate() - let identity = try PrivateKey.generate() - - // Prompt them to sign "XMTP : Create Identity ..." - let authorized = try await key.createIdentity(identity) - - // Create the `Authorization: Bearer $authToken` for API calls. - let authToken = try await authorized.createAuthToken() - - guard let tokenData = authToken.data(using: .utf8), - let base64TokenData = Data(base64Encoded: tokenData) - else { - XCTFail("could not get token data") - return - } - - let token = try Token(serializedData: base64TokenData) - let authData = try AuthData(serializedData: token.authDataBytes) - - XCTAssertEqual(authData.walletAddr, authorized.address) - } - - func testEnablingSavingAndLoadingOfStoredKeys() async throws { - let alice = try PrivateKey.generate() - let identity = try PrivateKey.generate() - - let authorized = try await alice.createIdentity(identity) - - let bundle = try authorized.toBundle - let encryptedBundle = try await bundle.encrypted(with: alice) - - let decrypted = try await encryptedBundle.decrypted(with: alice) - XCTAssertEqual(decrypted.v1.identityKey.secp256K1.bytes, identity.secp256K1.bytes) - XCTAssertEqual(decrypted.v1.identityKey.publicKey, authorized.authorized) - } -} diff --git a/Tests/XMTPTests/ClientTests.swift b/Tests/XMTPTests/ClientTests.swift index 21e4905e..040ecdef 100644 --- a/Tests/XMTPTests/ClientTests.swift +++ b/Tests/XMTPTests/ClientTests.swift @@ -1,70 +1,24 @@ -// -// ClientTests.swift -// -// -// Created by Pat Nakajima on 11/22/22. -// - import Foundation - -import XCTest -@testable import XMTPiOS import LibXMTP +import XCTest import XMTPTestHelpers +@testable import XMTPiOS + @available(iOS 15, *) class ClientTests: XCTestCase { func testTakesAWallet() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let fakeWallet = try PrivateKey.generate() - _ = try await Client.create(account: fakeWallet, options: opts) - } - - func testPassingSavedKeysWithNoSignerWithMLSErrors() async throws { let key = try Crypto.secureRandomBytes(count: 32) - let bo = try PrivateKey.generate() - - do { - let client = try await Client.create( - account: bo, - options: .init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) - ) - } catch { - XCTAssert(error.localizedDescription.contains("no keys")) - } - } - - func testPassingSavedKeysWithMLS() async throws { - let key = try Crypto.secureRandomBytes(count: 32) - let bo = try PrivateKey.generate() - let client = try await Client.create( - account: bo, - options: .init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) - ) - - let keys = try client.privateKeyBundle - let otherClient = try await Client.from( - bundle: keys, - options: .init( - api: .init(env: .local, isSecure: false), - // Should not need to pass the signer again - enableV3: true, - encryptionKey: key - ) + let clientOptions: ClientOptions = ClientOptions( + api: ClientOptions.Api( + env: XMTPEnvironment.local, isSecure: false), + dbEncryptionKey: key ) - - XCTAssertEqual(client.address, otherClient.address) + let fakeWallet = try PrivateKey.generate() + _ = try await Client.create(account: fakeWallet, options: clientOptions) } - func testPassingencryptionKey() async throws { + func testPassingEncryptionKey() async throws { let bo = try PrivateKey.generate() let key = try Crypto.secureRandomBytes(count: 32) @@ -72,27 +26,11 @@ class ClientTests: XCTestCase { account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) - - do { - _ = try await Client.create( - account: bo, - options: .init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: nil // No key should error - ) - ) - - XCTFail("did not throw") - } catch { - XCTAssert(true) - } } - + func testCanDeleteDatabase() async throws { let key = try Crypto.secureRandomBytes(count: 32) let bo = try PrivateKey.generate() @@ -101,24 +39,23 @@ class ClientTests: XCTestCase { account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) - + let alixClient = try await Client.create( account: alix, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) - _ = try await boClient.conversations.newGroup(with: [alixClient.address]) + _ = try await boClient.conversations.newGroup(with: [alixClient.address] + ) try await boClient.conversations.sync() - var groupCount = try await boClient.conversations.groups().count + var groupCount = try await boClient.conversations.listGroups().count XCTAssertEqual(groupCount, 1) assert(!boClient.dbPath.isEmpty) @@ -128,147 +65,81 @@ class ClientTests: XCTestCase { account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) try await boClient.conversations.sync() - groupCount = try await boClient.conversations.groups().count + groupCount = try await boClient.conversations.listGroups().count XCTAssertEqual(groupCount, 0) } - + func testCanDropReconnectDatabase() async throws { let key = try Crypto.secureRandomBytes(count: 32) let bo = try PrivateKey.generate() let alix = try PrivateKey.generate() - var boClient = try await Client.create( + let boClient = try await Client.create( account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) - + let alixClient = try await Client.create( account: alix, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) - _ = try await boClient.conversations.newGroup(with: [alixClient.address]) + _ = try await boClient.conversations.newGroup(with: [alixClient.address] + ) try await boClient.conversations.sync() - var groupCount = try await boClient.conversations.groups().count + var groupCount = try await boClient.conversations.listGroups().count XCTAssertEqual(groupCount, 1) try boClient.dropLocalDatabaseConnection() - await assertThrowsAsyncError(try await boClient.conversations.groups()) + await assertThrowsAsyncError( + try await boClient.conversations.listGroups()) try await boClient.reconnectLocalDatabase() - groupCount = try await boClient.conversations.groups().count + groupCount = try await boClient.conversations.listGroups().count XCTAssertEqual(groupCount, 1) } func testCanMessage() async throws { - let fixtures = await fixtures() + let fixtures = try await fixtures() let notOnNetwork = try PrivateKey.generate() - let canMessage = try await fixtures.aliceClient.canMessage(fixtures.bobClient.address) - let cannotMessage = try await fixtures.aliceClient.canMessage(notOnNetwork.address) + let canMessage = try await fixtures.alixClient.canMessage( + address: fixtures.boClient.address) + let cannotMessage = try await fixtures.alixClient.canMessage( + address: notOnNetwork.address) XCTAssertTrue(canMessage) XCTAssertFalse(cannotMessage) } - func testStaticCanMessage() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - - let aliceWallet = try PrivateKey.generate() - let notOnNetwork = try PrivateKey.generate() - let alice = try await Client.create(account: aliceWallet, options: opts) - - let canMessage = try await Client.canMessage(alice.address, options: opts) - let cannotMessage = try await Client.canMessage(notOnNetwork.address, options: opts) - XCTAssertTrue(canMessage) - XCTAssertFalse(cannotMessage) - } - - func testHasPrivateKeyBundleV1() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) + func testPreAuthenticateToInboxCallback() async throws { let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet, options: opts) - - XCTAssertEqual(1, try client.v1keys.preKeys.count) - - let preKey = try client.v1keys.preKeys[0] - - XCTAssert(preKey.publicKey.hasSignature, "prekey not signed") - } - - func testCanBeCreatedWithBundle() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet, options: opts) - - let bundle = try client.privateKeyBundle - let clientFromV1Bundle = try await Client.from(bundle: bundle, options: opts) - - XCTAssertEqual(client.address, clientFromV1Bundle.address) - XCTAssertEqual(try client.v1keys.identityKey, try clientFromV1Bundle.v1keys.identityKey) - XCTAssertEqual(try client.v1keys.preKeys, try clientFromV1Bundle.v1keys.preKeys) - } - - func testCanBeCreatedWithV1Bundle() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet, options: opts) - - let bundleV1 = try client.v1keys - let clientFromV1Bundle = try await Client.from(v1Bundle: bundleV1, options: opts) - - XCTAssertEqual(client.address, clientFromV1Bundle.address) - XCTAssertEqual(try client.v1keys.identityKey, try clientFromV1Bundle.v1keys.identityKey) - XCTAssertEqual(try client.v1keys.preKeys, try clientFromV1Bundle.v1keys.preKeys) - } - - func testCanAccessPublicKeyBundle() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet, options: opts) - - let publicKeyBundle = try client.keys.getPublicKeyBundle() - XCTAssertEqual(publicKeyBundle, try client.publicKeyBundle) - } - - func testCanSignWithPrivateIdentityKey() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let fakeWallet = try PrivateKey.generate() - let client = try await Client.create(account: fakeWallet, options: opts) - - let digest = Util.keccak256(Data("hello world".utf8)) - let signature = try await client.keys.identityKey.sign(digest) - - let recovered = try KeyUtilx.recoverPublicKeyKeccak256(from: signature.rawData, message: Data("hello world".utf8)) - let bytes = try client.keys.identityKey.publicKey.secp256K1Uncompressed.bytes - XCTAssertEqual(recovered, bytes) - } - - func testPreEnableIdentityCallback() async throws { - let fakeWallet = try PrivateKey.generate() - let expectation = XCTestExpectation(description: "preEnableIdentityCallback is called") + let expectation = XCTestExpectation( + description: "preAuthenticateToInboxCallback is called") + let key = try Crypto.secureRandomBytes(count: 32) - let preEnableIdentityCallback: () async throws -> Void = { - print("preEnableIdentityCallback called") - expectation.fulfill() + let preAuthenticateToInboxCallback: () async throws -> Void = { + print("preAuthenticateToInboxCallback called") + expectation.fulfill() } - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false), preEnableIdentityCallback: preEnableIdentityCallback ) + let opts = ClientOptions( + api: ClientOptions.Api(env: .local, isSecure: false), + preAuthenticateToInboxCallback: preAuthenticateToInboxCallback, + dbEncryptionKey: key + ) do { _ = try await Client.create(account: fakeWallet, options: opts) await XCTWaiter().fulfillment(of: [expectation], timeout: 30) @@ -277,49 +148,7 @@ class ClientTests: XCTestCase { } } - func testPreCreateIdentityCallback() async throws { - let fakeWallet = try PrivateKey.generate() - let expectation = XCTestExpectation(description: "preCreateIdentityCallback is called") - - let preCreateIdentityCallback: () async throws -> Void = { - print("preCreateIdentityCallback called") - expectation.fulfill() - } - - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false), preCreateIdentityCallback: preCreateIdentityCallback ) - do { - _ = try await Client.create(account: fakeWallet, options: opts) - await XCTWaiter().fulfillment(of: [expectation], timeout: 30) - } catch { - XCTFail("Error: \(error)") - } - } - - func testPreAuthenticateToInboxCallback() async throws { - let fakeWallet = try PrivateKey.generate() - let expectation = XCTestExpectation(description: "preAuthenticateToInboxCallback is called") - let key = try Crypto.secureRandomBytes(count: 32) - - let preAuthenticateToInboxCallback: () async throws -> Void = { - print("preAuthenticateToInboxCallback called") - expectation.fulfill() - } - - let opts = ClientOptions( - api: ClientOptions.Api(env: .local, isSecure: false), - preAuthenticateToInboxCallback: preAuthenticateToInboxCallback, - enableV3: true, - encryptionKey: key - ) - do { - _ = try await Client.create(account: fakeWallet, options: opts) - await XCTWaiter().fulfillment(of: [expectation], timeout: 30) - } catch { - XCTFail("Error: \(error)") - } - } - - func testPassingencryptionKeyAndDatabaseDirectory() async throws { + func testPassingEncryptionKeyAndDatabaseDirectory() async throws { let bo = try PrivateKey.generate() let key = try Crypto.secureRandomBytes(count: 32) @@ -327,19 +156,16 @@ class ClientTests: XCTestCase { account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key, + dbEncryptionKey: key, dbDirectory: "xmtp_db" ) ) - let keys = try client.privateKeyBundle - let bundleClient = try await Client.from( - bundle: keys, + let bundleClient = try await Client.build( + address: bo.address, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key, + dbEncryptionKey: key, dbDirectory: "xmtp_db" ) ) @@ -349,30 +175,17 @@ class ClientTests: XCTestCase { XCTAssert(!client.installationID.isEmpty) await assertThrowsAsyncError( - _ = try await Client.from( - bundle: keys, - options: .init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: nil, - dbDirectory: "xmtp_db" - ) - ) - ) - - await assertThrowsAsyncError( - _ = try await Client.from( - bundle: keys, + _ = try await Client.build( + address: bo.address, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key, + dbEncryptionKey: key, dbDirectory: nil ) ) ) } - + func testEncryptionKeyCanDecryptCorrectly() async throws { let bo = try PrivateKey.generate() let alix = try PrivateKey.generate() @@ -382,38 +195,37 @@ class ClientTests: XCTestCase { account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key, + dbEncryptionKey: key, dbDirectory: "xmtp_db" ) ) - + let alixClient = try await Client.create( account: alix, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key, + dbEncryptionKey: key, dbDirectory: "xmtp_db" ) ) - let group = try await boClient.conversations.newGroup(with: [alixClient.address]) - + _ = try await boClient.conversations.newGroup(with: [ + alixClient.address + ]) + let key2 = try Crypto.secureRandomBytes(count: 32) await assertThrowsAsyncError( try await Client.create( account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key2, + dbEncryptionKey: key2, dbDirectory: "xmtp_db" ) ) ) } - + func testCanGetAnInboxIdFromAddress() async throws { let key = try Crypto.secureRandomBytes(count: 32) let bo = try PrivateKey.generate() @@ -422,34 +234,32 @@ class ClientTests: XCTestCase { account: bo, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) - + let alixClient = try await Client.create( account: alix, options: .init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key + dbEncryptionKey: key ) ) - let boInboxId = try await alixClient.inboxIdFromAddress(address: boClient.address) + let boInboxId = try await alixClient.inboxIdFromAddress( + address: boClient.address) XCTAssertEqual(boClient.inboxID, boInboxId) } - + func testCreatesAV3Client() async throws { let key = try Crypto.secureRandomBytes(count: 32) let alix = try PrivateKey.generate() let options = ClientOptions.init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) - + dbEncryptionKey: key + ) - let inboxId = try await Client.getOrCreateInboxId(options: options, address: alix.address) + let inboxId = try await Client.getOrCreateInboxId( + options: options, address: alix.address) let alixClient = try await Client.create( account: alix, options: options @@ -457,41 +267,39 @@ class ClientTests: XCTestCase { XCTAssertEqual(inboxId, alixClient.inboxID) } - - func testCreatesAPureV3Client() async throws { + + func testCreatesAClient() async throws { let key = try Crypto.secureRandomBytes(count: 32) let alix = try PrivateKey.generate() let options = ClientOptions.init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) - + dbEncryptionKey: key + ) - let inboxId = try await Client.getOrCreateInboxId(options: options, address: alix.address) - let alixClient = try await Client.createV3( + let inboxId = try await Client.getOrCreateInboxId( + options: options, address: alix.address) + let alixClient = try await Client.create( account: alix, options: options ) XCTAssertEqual(inboxId, alixClient.inboxID) - - let alixClient2 = try await Client.buildV3( + + let alixClient2 = try await Client.build( address: alix.address, options: options ) - + XCTAssertEqual(alixClient2.inboxID, alixClient.inboxID) } - + func testRevokesAllOtherInstallations() async throws { let key = try Crypto.secureRandomBytes(count: 32) let alix = try PrivateKey.generate() let options = ClientOptions.init( api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) + dbEncryptionKey: key + ) let alixClient = try await Client.create( account: alix, @@ -499,7 +307,7 @@ class ClientTests: XCTestCase { ) try alixClient.dropLocalDatabaseConnection() try alixClient.deleteLocalDatabase() - + let alixClient2 = try await Client.create( account: alix, options: options @@ -511,38 +319,15 @@ class ClientTests: XCTestCase { account: alix, options: options ) - + let state = try await alixClient3.inboxState(refreshFromNetwork: true) XCTAssertEqual(state.installations.count, 3) XCTAssert(state.installations.first?.createdAt != nil) - - try await alixClient3.revokeAllOtherInstallations(signingKey: alix) - - let newState = try await alixClient3.inboxState(refreshFromNetwork: true) - XCTAssertEqual(newState.installations.count, 1) - } - - func testCreatesASCWClient() async throws { - throw XCTSkip("TODO: Need to write a SCW local deploy with anvil") - let key = try Crypto.secureRandomBytes(count: 32) - let alix = try FakeSCWWallet.generate() - let options = ClientOptions.init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) + try await alixClient3.revokeAllOtherInstallations(signingKey: alix) - let inboxId = try await Client.getOrCreateInboxId(options: options, address: alix.address) - - let alixClient = try await Client.createV3( - account: alix, - options: options - ) - - let alixClient2 = try await Client.buildV3(address: alix.address, options: options) - XCTAssertEqual(inboxId, alixClient.inboxID) - XCTAssertEqual(alixClient2.inboxID, alixClient.inboxID) - + let newState = try await alixClient3.inboxState( + refreshFromNetwork: true) + XCTAssertEqual(newState.installations.count, 1) } } diff --git a/Tests/XMTPTests/CodecTests.swift b/Tests/XMTPTests/CodecTests.swift index d3f7fd3b..bb406ccf 100644 --- a/Tests/XMTPTests/CodecTests.swift +++ b/Tests/XMTPTests/CodecTests.swift @@ -1,38 +1,40 @@ -// -// CodecTests.swift -// -// -// Created by Pat Nakajima on 12/21/22. -// - import XCTest + @testable import XMTPiOS struct NumberCodec: ContentCodec { func shouldPush(content: Double) throws -> Bool { return false } - + func fallback(content: Double) throws -> String? { return "pi" } - + typealias T = Double var contentType: XMTPiOS.ContentTypeID { - ContentTypeID(authorityID: "example.com", typeID: "number", versionMajor: 1, versionMinor: 1) + ContentTypeID( + authorityID: "example.com", typeID: "number", versionMajor: 1, + versionMinor: 1) } - func encode(content: Double, client _: Client) throws -> XMTPiOS.EncodedContent { + func encode(content: Double, client _: Client) throws + -> XMTPiOS.EncodedContent + { var encodedContent = EncodedContent() - encodedContent.type = ContentTypeID(authorityID: "example.com", typeID: "number", versionMajor: 1, versionMinor: 1) + encodedContent.type = ContentTypeID( + authorityID: "example.com", typeID: "number", versionMajor: 1, + versionMinor: 1) encodedContent.content = try JSONEncoder().encode(content) return encodedContent } - func decode(content: XMTPiOS.EncodedContent, client _: Client) throws -> Double { + func decode(content: XMTPiOS.EncodedContent, client _: Client) throws + -> Double + { return try JSONDecoder().decode(Double.self, from: content.content) } } @@ -40,17 +42,20 @@ struct NumberCodec: ContentCodec { @available(iOS 15, *) class CodecTests: XCTestCase { func testCanRoundTripWithCustomContentType() async throws { - let fixtures = await fixtures() + let fixtures = try await fixtures() - let aliceClient = fixtures.aliceClient! - let aliceConversation = try await aliceClient.conversations.newConversation(with: fixtures.bob.address) + let alixClient = fixtures.alixClient! + let alixConversation = try await alixClient.conversations + .newConversation(with: fixtures.bo.address) - aliceClient.register(codec: NumberCodec()) + alixClient.register(codec: NumberCodec()) - try await aliceConversation.send(content: 3.14, options: .init(contentType: NumberCodec().contentType)) + try await alixConversation.send( + content: 3.14, + options: .init(contentType: NumberCodec().contentType)) - let messages = try await aliceConversation.messages() - XCTAssertEqual(messages.count, 1) + let messages = try await alixConversation.messages() + XCTAssertEqual(messages.count, 2) if messages.count == 1 { let content: Double = try messages[0].content() @@ -59,49 +64,26 @@ class CodecTests: XCTestCase { } func testFallsBackToFallbackContentWhenCannotDecode() async throws { - let fixtures = await fixtures() + let fixtures = try await fixtures() - let aliceClient = fixtures.aliceClient! - let aliceConversation = try await aliceClient.conversations.newConversation(with: fixtures.bob.address) + let alixClient = fixtures.alixClient! + let alixConversation = try await alixClient.conversations + .newConversation(with: fixtures.bo.address) - aliceClient.register(codec: NumberCodec()) + alixClient.register(codec: NumberCodec()) - try await aliceConversation.send(content: 3.14, options: .init(contentType: NumberCodec().contentType)) + try await alixConversation.send( + content: 3.14, + options: .init(contentType: NumberCodec().contentType)) // Remove number codec from registry - aliceClient.codecRegistry.codecs.removeValue(forKey: NumberCodec().id) + alixClient.codecRegistry.codecs.removeValue(forKey: NumberCodec().id) - let messages = try await aliceConversation.messages() - XCTAssertEqual(messages.count, 1) + let messages = try await alixConversation.messages() + XCTAssertEqual(messages.count, 2) let content: Double? = try? messages[0].content() XCTAssertEqual(nil, content) XCTAssertEqual("pi", messages[0].fallbackContent) } - - func testCanGetPushInfoBeforeDecoded() async throws { - let fixtures = await fixtures() - - let aliceClient = fixtures.aliceClient! - let aliceConversation = try await aliceClient.conversations.newConversation(with: fixtures.bob.address) - - aliceClient.register(codec: NumberCodec()) - - try await aliceConversation.send(content: 3.14, options: .init(contentType: NumberCodec().contentType)) - - let messages = try await aliceConversation.messages() - XCTAssertEqual(messages.count, 1) - - let message = try await MessageV2.encode( - client: aliceClient, - content: messages[0].encodedContent, - topic: aliceConversation.topic, - keyMaterial: Data(aliceConversation.keyMaterial!), - codec: NumberCodec() - ) - - XCTAssertEqual(false, message.shouldPush) - XCTAssert(!message.senderHmac.isEmpty) - - } } diff --git a/Tests/XMTPTests/ContactTests.swift b/Tests/XMTPTests/ContactTests.swift deleted file mode 100644 index 838942e0..00000000 --- a/Tests/XMTPTests/ContactTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// ContactTests.swift -// -// -// Created by Pat Nakajima on 11/23/22. -// - -import XCTest -@testable import XMTPiOS - -class ContactTests: XCTestCase { - func testParsingV2Bundle() throws { - let data = Data( - [ - 18, 181, 2, 10, 178, 2, 10, 150, 1, 10, 76, 8, 140, 241, 170, 138, 182, - 48, 26, 67, 10, 65, 4, 33, 132, 132, 43, 80, 179, 54, 132, 47, 151, 245, - 23, 108, 148, 94, 190, 2, 33, 232, 232, 185, 73, 64, 44, 47, 65, 168, 25, - 56, 252, 1, 58, 243, 20, 103, 8, 253, 118, 10, 1, 108, 158, 125, 149, 128, - 37, 28, 250, 204, 1, 66, 194, 61, 119, 197, 121, 158, 210, 234, 92, 79, - 181, 1, 150, 18, 70, 18, 68, 10, 64, 43, 154, 228, 249, 69, 206, 218, 165, - 35, 55, 141, 145, 183, 129, 104, 75, 106, 62, 28, 73, 69, 7, 170, 65, 66, - 93, 11, 184, 229, 204, 140, 101, 71, 74, 0, 227, 140, 89, 53, 35, 203, 180, - 87, 102, 89, 176, 57, 128, 165, 42, 214, 173, 199, 17, 159, 200, 254, 25, - 80, 227, 20, 16, 189, 92, 16, 1, 18, 150, 1, 10, 76, 8, 244, 246, 171, 138, - 182, 48, 26, 67, 10, 65, 4, 104, 191, 167, 212, 49, 159, 46, 123, 133, 52, - 69, 73, 137, 157, 76, 63, 233, 223, 129, 64, 138, 86, 91, 26, 191, 241, 109, - 249, 216, 96, 226, 255, 103, 29, 192, 3, 181, 228, 63, 52, 101, 88, 96, 141, - 236, 194, 111, 16, 105, 88, 127, 215, 255, 63, 92, 135, 251, 14, 176, 85, 65, - 211, 88, 80, 18, 70, 10, 68, 10, 64, 252, 165, 96, 161, 187, 19, 203, 60, 89, - 195, 73, 176, 189, 203, 109, 113, 106, 39, 71, 116, 44, 101, 180, 16, 243, - 70, 128, 58, 46, 10, 55, 243, 43, 115, 21, 23, 153, 241, 208, 212, 162, 205, - 197, 139, 2, 117, 1, 40, 200, 252, 136, 148, 18, 125, 39, 175, 130, 113, - 103, 83, 120, 60, 232, 109, 16, 1, - ] - ) - - var envelope = Envelope() - envelope.message = data - let contactBundle = try ContactBundle.from(envelope: envelope) - - XCTAssert(!contactBundle.v1.hasKeyBundle) - XCTAssert(contactBundle.v2.hasKeyBundle) - - XCTAssertEqual(contactBundle.walletAddress, "0x66942eC8b0A6d0cff51AEA9C7fd00494556E705F") - } - - func testParsingV1Bundle() throws { - let message = Data( - [ - // This is a serialized PublicKeyBundle (instead of a ContactBundle) - 10, 146, 1, 8, 236, 130, 192, 166, 148, 48, 18, 68, - 10, 66, 10, 64, 70, 34, 101, 46, 39, 87, 114, 210, - 103, 135, 87, 49, 162, 200, 82, 177, 11, 4, 137, - 31, 235, 91, 185, 46, 177, 208, 228, 102, 44, 61, - 40, 131, 109, 210, 93, 42, 44, 235, 177, 73, 72, - 234, 18, 32, 230, 61, 146, 58, 65, 78, 178, 163, - 164, 241, 118, 167, 77, 240, 13, 100, 151, 70, 190, - 15, 26, 67, 10, 65, 4, 8, 71, 173, 223, 174, 185, - 150, 4, 179, 111, 144, 35, 5, 210, 6, 60, 21, 131, - 135, 52, 37, 221, 72, 126, 21, 103, 208, 31, 182, - 76, 187, 72, 66, 92, 193, 74, 161, 45, 135, 204, - 55, 10, 20, 119, 145, 136, 45, 194, 140, 164, 124, - 47, 238, 17, 198, 243, 102, 171, 67, 128, 164, 117, - 7, 83, - ] - ) - - var envelope = Envelope() - envelope.message = message - - let contactBundle = try ContactBundle.from(envelope: envelope) - XCTAssertEqual(contactBundle.walletAddress, "0x66942eC8b0A6d0cff51AEA9C7fd00494556E705F") - XCTAssertEqual(contactBundle.identityAddress, "0xD320f1454e33ab9393c0cc596E6321d80e4b481e") - XCTAssert(contactBundle.v1.keyBundle.hasPreKey == false, "should not have pre key") - } -} diff --git a/Tests/XMTPTests/ContactsTests.swift b/Tests/XMTPTests/ContactsTests.swift deleted file mode 100644 index 6efcd574..00000000 --- a/Tests/XMTPTests/ContactsTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ContactsTests.swift -// -// -// Created by Pat Nakajima on 12/8/22. -// - -import XCTest -@testable import XMTPiOS -import XMTPTestHelpers - -@available(iOS 15, *) -class ContactsTests: XCTestCase { - func testNormalizesAddresses() async throws { - let fixtures = await fixtures() - try await fixtures.bobClient.ensureUserContactPublished() - - let bobAddressLowerCased = fixtures.bobClient.address.lowercased() - let bobContact = try await fixtures.aliceClient.getUserContact(peerAddress: bobAddressLowerCased) - - XCTAssertNotNil(bobContact) - } - - func testCanFindContact() async throws { - let fixtures = await fixtures() - - try await fixtures.bobClient.ensureUserContactPublished() - guard let contactBundle = try await fixtures.aliceClient.contacts.find(fixtures.bob.walletAddress) else { - XCTFail("did not find contact bundle") - return - } - - XCTAssertEqual(contactBundle.walletAddress, fixtures.bob.walletAddress) - } - - func testAllowAddress() async throws { - let fixtures = await fixtures() - - let contacts = fixtures.bobClient.contacts - var result = try await contacts.isAllowed(fixtures.alice.address) - - XCTAssertFalse(result) - - try await contacts.allow(addresses: [fixtures.alice.address]) - - result = try await contacts.isAllowed(fixtures.alice.address) - XCTAssertTrue(result) - } - - func testDenyAddress() async throws { - let fixtures = await fixtures() - - let contacts = fixtures.bobClient.contacts - var result = try await contacts.isAllowed(fixtures.alice.address) - - XCTAssertFalse(result) - - try await contacts.deny(addresses: [fixtures.alice.address]) - - result = try await contacts.isDenied(fixtures.alice.address) - XCTAssertTrue(result) - } - - func testHandleMultipleAddresses() async throws { - let fixtures = await fixtures() - let caro = try PrivateKey.generate() - let caroClient = try await Client.create(account: caro, options: fixtures.clientOptions) - - let contacts = fixtures.bobClient.contacts - var result = try await contacts.isAllowed(fixtures.alice.address) - XCTAssertFalse(result) - result = try await contacts.isAllowed(caroClient.address) - XCTAssertFalse(result) - - try await contacts.deny(addresses: [fixtures.alice.address, caroClient.address]) - - var aliceResult = try await contacts.isDenied(fixtures.alice.address) - XCTAssertTrue(aliceResult) - var caroResult = try await contacts.isDenied(fixtures.alice.address) - XCTAssertTrue(caroResult) - try await contacts.allow(addresses: [fixtures.alice.address, caroClient.address]) - aliceResult = try await contacts.isAllowed(fixtures.alice.address) - XCTAssertTrue(aliceResult) - caroResult = try await contacts.isAllowed(fixtures.alice.address) - XCTAssertTrue(caroResult) - } -} diff --git a/Tests/XMTPTests/ConversationTests.swift b/Tests/XMTPTests/ConversationTests.swift deleted file mode 100644 index 713829e0..00000000 --- a/Tests/XMTPTests/ConversationTests.swift +++ /dev/null @@ -1,500 +0,0 @@ -// -// ConversationTests.swift -// -// -// Created by Pat Nakajima on 12/6/22. -// - -import CryptoKit -import XCTest -@testable import XMTPiOS -import XMTPTestHelpers - -@available(iOS 16, *) -class ConversationTests: XCTestCase { - var alice: PrivateKey! - var aliceClient: Client! - - var bob: PrivateKey! - var bobClient: Client! - - var fixtures: Fixtures! - - override func setUp() async throws { - fixtures = await fixtures() - - alice = fixtures.alice - bob = fixtures.bob - - aliceClient = fixtures.aliceClient - bobClient = fixtures.bobClient - } - - func testCanPrepareV2Message() async throws { - let conversation = try await aliceClient.conversations.newConversation(with: bob.address) - let preparedMessage = try await conversation.prepareMessage(content: "hi") - let messageID = preparedMessage.messageID - - try await conversation.send(prepared: preparedMessage) - - let messages = try await conversation.messages() - let message = messages[0] - - XCTAssertEqual("hi", message.body) - XCTAssertEqual(message.id, messageID) - } - - func testCanSendPreparedMessagesWithoutAConversation() async throws { - let conversation = try await aliceClient.conversations.newConversation(with: bob.address) - let preparedMessage = try await conversation.prepareMessage(content: "hi") - let messageID = preparedMessage.messageID - - // This does not need the `conversation` to `.publish` the message. - // This simulates a background task publishes all pending messages upon connection. - try await aliceClient.publish(envelopes: preparedMessage.envelopes) - - let messages = try await conversation.messages() - let message = messages[0] - - XCTAssertEqual("hi", message.body) - XCTAssertEqual(message.id, messageID) - } - - func testV2RejectsSpoofedContactBundles() async throws { - let topic = - "/xmtp/0/m-Gdb7oj5nNdfZ3MJFLAcS4WTABgr6al1hePy6JV1-QUE/proto" - guard let envelopeMessage = Data(base64String: "Er0ECkcIwNruhKLgkKUXEjsveG10cC8wL20tR2RiN29qNW5OZGZaM01KRkxBY1M0V1RBQmdyNmFsMWhlUHk2SlYxLVFVRS9wcm90bxLxAwruAwognstLoG6LWgiBRsWuBOt+tYNJz+CqCj9zq6hYymLoak8SDFsVSy+cVAII0/r3sxq7A/GCOrVtKH6J+4ggfUuI5lDkFPJ8G5DHlysCfRyFMcQDIG/2SFUqSILAlpTNbeTC9eSI2hUjcnlpH9+ncFcBu8StGfmilVGfiADru2fGdThiQ+VYturqLIJQXCHO2DkvbbUOg9xI66E4Hj41R9vE8yRGeZ/eRGRLRm06HftwSQgzAYf2AukbvjNx/k+xCMqti49Qtv9AjzxVnwttLiA/9O+GDcOsiB1RQzbZZzaDjQ/nLDTF6K4vKI4rS9QwzTJqnoCdp0SbMZFf+KVZpq3VWnMGkMxLW5Fr6gMvKny1e1LAtUJSIclI/1xPXu5nsKd4IyzGb2ZQFXFQ/BVL9Z4CeOZTsjZLGTOGS75xzzGHDtKohcl79+0lgIhAuSWSLDa2+o2OYT0fAjChp+qqxXcisAyrD5FB6c9spXKfoDZsqMV/bnCg3+udIuNtk7zBk7jdTDMkofEtE3hyIm8d3ycmxKYOakDPqeo+Nk1hQ0ogxI8Z7cEoS2ovi9+rGBMwREzltUkTVR3BKvgV2EOADxxTWo7y8WRwWxQ+O6mYPACsiFNqjX5Nvah5lRjihphQldJfyVOG8Rgf4UwkFxmI"), - let keyMaterial = Data(base64String: "R0BBM5OPftNEuavH/991IKyJ1UqsgdEG4SrdxlIG2ZY=") - else { - XCTFail("did not have correct setup data") - return - } - - let conversation = ConversationV2(topic: topic, keyMaterial: keyMaterial, context: .init(), peerAddress: "0x2f25e33D7146602Ec08D43c1D6B1b65fc151A677", client: aliceClient) - - let envelope = Envelope(topic: topic, timestamp: Date(), message: envelopeMessage) - XCTAssertThrowsError(try conversation.decode(envelope: envelope)) { error in - switch error as! MessageV2Error { - case let .decodeError(message): - XCTAssertEqual(message, "pre-key not signed by identity key") - default: - XCTFail("did not raise correct error") - } - } - } - - func testDoesNotAllowConversationWithSelf() async throws { - let expectation = XCTestExpectation(description: "convo with self throws") - let client = aliceClient! - - do { - _ = try await client.conversations.newConversation(with: alice.walletAddress) - } catch { - expectation.fulfill() - } - - await fulfillment(of: [expectation], timeout: 3) - } - - func testCanStreamConversationsV2() async throws { - let options = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let wallet = try PrivateKey.generate() - let client = try await Client.create(account: wallet, options: options) - - let wallet2 = try PrivateKey.generate() - let client2 = try await Client.create(account: wallet2, options: options) - let expectation1 = XCTestExpectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - - Task(priority: .userInitiated) { - for try await conversation in try await client.conversations.stream() { - expectation1.fulfill() - } - } - - guard case let .v2(conversation) = try await client.conversations.newConversation(with: client2.address) else { - XCTFail("Did not create a v2 convo") - return - } - try? await Task.sleep(nanoseconds: 1_000_000_000) - - try await conversation.send(content: "hi") - - guard case let .v2(conversation) = try await client.conversations.newConversation(with: client2.address) else { - XCTFail("Did not create a v2 convo") - return - } - - try? await Task.sleep(nanoseconds: 15_000_000_000) - - try await conversation.send(content: "hi again") - - let newWallet = try PrivateKey.generate() - let newClient = try await Client.create(account: newWallet, options: options) - - guard case let .v2(conversation2) = try await client.conversations.newConversation(with: newWallet.walletAddress) else { - XCTFail("Did not create a v2 convo") - return - } - try? await Task.sleep(nanoseconds: 1_000_000_000) - - try await conversation2.send(content: "hi from new wallet") - - await fulfillment(of: [expectation1], timeout: 30) - } - - func publishLegacyContact(client: Client) async throws { - var contactBundle = ContactBundle() - contactBundle.v1.keyBundle = try client.v1keys.toPublicKeyBundle() - - var envelope = Envelope() - envelope.contentTopic = Topic.contact(client.address).description - envelope.timestampNs = UInt64(Date().millisecondsSinceEpoch * 1_000_000) - envelope.message = try contactBundle.serializedData() - - try await client.publish(envelopes: [envelope]) - } - - func testStreamingMessagesFromV2Conversations() async throws { - guard case let .v2(conversation) = try await aliceClient.conversations.newConversation(with: bob.walletAddress) else { - XCTFail("Did not get a v2 convo") - return - } - - let expectation = XCTestExpectation(description: "got a message") - - Task(priority: .userInitiated) { - for try await message in conversation.streamMessages() { - if message.body == "hi alice" { - expectation.fulfill() - } - } - } - - // Stream a message - try await conversation.send(content: "hi alice") - - await fulfillment(of: [expectation], timeout: 3) - } - - func testCanLoadV2Messages() async throws { - guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - guard case let .v2(aliceConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - try await bobConversation.send(content: "hey alice") - let messages = try await aliceConversation.messages() - - XCTAssertEqual(1, messages.count) - XCTAssertEqual("hey alice", messages[0].body) - XCTAssertEqual(bob.address, messages[0].senderAddress) - } - - func testVerifiesV2MessageSignature() async throws { - guard case let .v2(aliceConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - let codec = TextCodec() - let originalContentText = "hello" - let originalContent = try codec.encode(content: originalContentText, client: aliceClient) - let tamperedContent = try codec.encode(content: "this is a fake", client: aliceClient) - - let originalPayload = try originalContent.serializedData() - let tamperedPayload = try tamperedContent.serializedData() - - let date = Date() - let header = MessageHeaderV2(topic: aliceConversation.topic, created: date) - let headerBytes = try header.serializedData() - - let digest = SHA256.hash(data: headerBytes + tamperedPayload) - let preKey = try aliceClient.keys.preKeys[0] - let signature = try await preKey.sign(Data(digest)) - - let bundle = try aliceClient.v1keys.toV2().getPublicKeyBundle() - - let signedContent = SignedContent(payload: originalPayload, sender: bundle, signature: signature) - let signedBytes = try signedContent.serializedData() - - let ciphertext = try Crypto.encrypt(aliceConversation.keyMaterial, signedBytes, additionalData: headerBytes) - - let thirtyDayPeriodsSinceEpoch = Int(date.timeIntervalSince1970 / 60 / 60 / 24 / 30) - let info = "\(thirtyDayPeriodsSinceEpoch)-\(aliceClient.address)" - let infoEncoded = info.data(using: .utf8) - - let senderHmac = try Crypto.generateHmacSignature(secret: aliceConversation.keyMaterial, info: infoEncoded!, message: headerBytes) - - let shouldPush = try codec.shouldPush(content: originalContentText) - - let tamperedMessage = MessageV2( - headerBytes: headerBytes, - ciphertext: ciphertext, - senderHmac: senderHmac, - shouldPush: shouldPush - ) - - try await aliceClient.publish(envelopes: [ - Envelope(topic: aliceConversation.topic, timestamp: Date(), message: Message(v2: tamperedMessage).serializedData()), - ]) - - guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - let messages = try await bobConversation.messages() - XCTAssertEqual(0, messages.count, "did not filter out tampered message") - } - - func testCanPaginateV1Messages() async throws { - throw XCTSkip("this test is flakey in CI, TODO: figure it out") - // Overwrite contact as legacy so we can get v1 - try await publishLegacyContact(client: bobClient) - try await publishLegacyContact(client: aliceClient) - - guard case let .v1(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address) else { - XCTFail("did not get a v1 conversation for alice") - return - } - - guard case let .v1(aliceConversation) = try await aliceClient.conversations.newConversation(with: bob.address) else { - XCTFail("did not get a v1 conversation for alice") - return - } - - // This is just to verify that the fake API client can handle limits larger how many envelopes it knows about - _ = try await aliceConversation.messages(limit: -1) - - try await bobConversation.send(content: "hey alice 1", sentAt: Date().addingTimeInterval(-1000)) - try await bobConversation.send(content: "hey alice 2", sentAt: Date().addingTimeInterval(-500)) - try await bobConversation.send(content: "hey alice 3", sentAt: Date()) - - let messages = try await aliceConversation.messages(limit: 1) - XCTAssertEqual(1, messages.count) - XCTAssertEqual("hey alice 3", messages[0].body) - XCTAssertEqual(aliceConversation.topic.description, messages[0].topic) - - let messages2 = try await aliceConversation.messages(limit: 1, before: messages[0].sent) - XCTAssertEqual(1, messages2.count) - XCTAssertEqual("hey alice 2", messages2[0].body) - - // This is just to verify that the fake API client can handle limits larger how many envelopes it knows about - _ = try await aliceConversation.messages(limit: 10) - } - - func testCanPaginateV2Messages() async throws { - guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - guard case let .v2(aliceConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - try await bobConversation.send(content: "hey alice 1", sentAt: Date().addingTimeInterval(-1000)) - try await bobConversation.send(content: "hey alice 2", sentAt: Date().addingTimeInterval(-500)) - try await bobConversation.send(content: "hey alice 3", sentAt: Date()) - - let messages = try await aliceConversation.messages(limit: 1) - XCTAssertEqual(1, messages.count) - XCTAssertEqual("hey alice 3", messages[0].body) - XCTAssertEqual(aliceConversation.topic, messages[0].topic) - - let messages2 = try await aliceConversation.messages(limit: 1, before: messages[0].sent) - XCTAssertEqual(1, messages2.count) - XCTAssertEqual("hey alice 2", messages2[0].body) - } - - func testCanRetrieveAllMessages() async throws { - guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for bob") - return - } - - guard case let .v2(aliceConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - for i in 0 ..< 110 { - do { - let content = "hey alice \(i)" - let sentAt = Date().addingTimeInterval(-1000) - try await bobConversation.send(content: content, sentAt: sentAt) - } catch { - print("Error sending message:", error) - } - } - - let messages = try await aliceConversation.messages() - XCTAssertEqual(110, messages.count) - } - - func testCanRetrieveBatchMessages() async throws { - guard case let .v2(bobConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for bob") - return - } - - for i in 0 ..< 3 { - do { - let content = "hey alice \(i)" - let sentAt = Date().addingTimeInterval(-1000) - try await bobConversation.send(content: content, sentAt: sentAt) - } catch { - print("Error sending message:", error) - } - } - - let messages = try await aliceClient.conversations.listBatchMessages( - topics: [bobConversation.topic: Pagination(limit: 3)] - ) - XCTAssertEqual(3, messages.count) - XCTAssertEqual(bobConversation.topic, messages[0].topic) - XCTAssertEqual(bobConversation.topic, messages[1].topic) - XCTAssertEqual(bobConversation.topic, messages[2].topic) - } - - func testProperlyDiscardBadBatchMessages() async throws { - guard case let .v2(bobConversation) = try await aliceClient.conversations - .newConversation(with: bob.address) - else { - XCTFail("did not get a v2 conversation for bob") - return - } - - try await bobConversation.send(content: "Hello") - - // Now we send some garbage and expect it to be properly ignored. - try await bobClient.apiClient!.publish(envelopes: [ - Envelope( - topic: bobConversation.topic, - timestamp: Date(), - message: Data([1, 2, 3]) // garbage, malformed message - ), - ]) - - try await bobConversation.send(content: "Goodbye") - - let messages = try await aliceClient.conversations.listBatchMessages( - topics: [bobConversation.topic: nil] - ) - XCTAssertEqual(2, messages.count) - XCTAssertEqual("Goodbye", try messages[0].content()) - XCTAssertEqual("Hello", try messages[1].content()) - } - - func testImportV1ConversationFromJS() async throws { - let jsExportJSONData = Data(""" - { - "version": "v1", - "peerAddress": "0x5DAc8E2B64b8523C11AF3e5A2E087c2EA9003f14", - "createdAt": "2022-09-20T09:32:50.329Z" - } - """.utf8) - - let conversation = try aliceClient.importConversation(from: jsExportJSONData) - - XCTAssertEqual(try conversation?.peerAddress, "0x5DAc8E2B64b8523C11AF3e5A2E087c2EA9003f14") - } - - func testImportV2ConversationFromJS() async throws { - let jsExportJSONData = Data(""" - {"version":"v2","topic":"/xmtp/0/m-2SkdN5Qa0ZmiFI5t3RFbfwIS-OLv5jusqndeenTLvNg/proto","keyMaterial":"ATA1L0O2aTxHmskmlGKCudqfGqwA1H+bad3W/GpGOr8=","peerAddress":"0x436D906d1339fC4E951769b1699051f020373D04","createdAt":"2023-01-26T22:58:45.068Z","context":{"conversationId":"pat/messageid","metadata":{}}} - """.utf8) - - let conversation = try aliceClient.importConversation(from: jsExportJSONData) - XCTAssertEqual(try conversation?.peerAddress, "0x436D906d1339fC4E951769b1699051f020373D04") - } - - func testImportV2ConversationWithNoContextFromJS() async throws { - let jsExportJSONData = Data(""" - {"version":"v2","topic":"/xmtp/0/m-2SkdN5Qa0ZmiFI5t3RFbfwIS-OLv5jusqndeenTLvNg/proto","keyMaterial":"ATA1L0O2aTxHmskmlGKCudqfGqwA1H+bad3W/GpGOr8=","peerAddress":"0x436D906d1339fC4E951769b1699051f020373D04","createdAt":"2023-01-26T22:58:45.068Z"} - """.utf8) - - guard case let .v2(conversation) = try aliceClient.importConversation(from: jsExportJSONData) else { - XCTFail("did not get a v2 conversation") - return - } - - XCTAssertEqual(conversation.peerAddress, "0x436D906d1339fC4E951769b1699051f020373D04") - } - - func testCanSendEncodedContentV2Message() async throws { - guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v1 conversation for alice") - return - } - - guard case let .v2(aliceConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v1 conversation for alice") - return - } - - let encodedContent = try TextCodec().encode(content: "hi", client: aliceClient) - - try await bobConversation.send(encodedContent: encodedContent) - - let messages = try await aliceConversation.messages() - - XCTAssertEqual(1, messages.count) - XCTAssertEqual("hi", try messages[0].content()) - } - - func testCanHaveConsentState() async throws { - let bobConversation = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) - let isAllowed = (try await bobConversation.consentState()) == .allowed - - // Conversations you start should start as allowed - XCTAssertTrue(isAllowed) - - try await bobClient.contacts.deny(addresses: [alice.address]) - _ = try await bobClient.contacts.refreshConsentList() - - let isDenied = (try await bobConversation.consentState()) == .denied - - XCTAssertTrue(isDenied) - - let aliceConversation = (try await aliceClient.conversations.list())[0] - let isUnknown = (try await aliceConversation.consentState()) == .unknown - - // Conversations started with you should start as unknown - XCTAssertTrue(isUnknown) - - try await aliceClient.contacts.allow(addresses: [bob.address]) - - let isBobAllowed = (try await aliceConversation.consentState()) == .allowed - XCTAssertTrue(isBobAllowed) - } - - func testCanHaveImplicitConsentOnMessageSend() async throws { - let bobConversation = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) - let isAllowed = (try await bobConversation.consentState()) == .allowed - - // Conversations you start should start as allowed - XCTAssertTrue(isAllowed) - - - let aliceConversation = (try await aliceClient.conversations.list())[0] - let isUnknown = (try await aliceConversation.consentState()) == .unknown - - // Conversations started with you should start as unknown - XCTAssertTrue(isUnknown) - - try await aliceConversation.send(content: "hey bob") - _ = try await aliceClient.contacts.refreshConsentList() - let isNowAllowed = (try await aliceConversation.consentState()) == .allowed - - // Conversations you send a message to get marked as allowed - XCTAssertTrue(isNowAllowed) - } -} diff --git a/Tests/XMTPTests/ConversationsTest.swift b/Tests/XMTPTests/ConversationsTest.swift deleted file mode 100644 index ffbc5824..00000000 --- a/Tests/XMTPTests/ConversationsTest.swift +++ /dev/null @@ -1,256 +0,0 @@ -// -// ConversationsTests.swift -// -// -// Created by Pat on 2/16/23. -// - -import Foundation -import XCTest -@testable import XMTPiOS -import XMTPTestHelpers -import CryptoKit - -@available(macOS 13.0, *) -@available(iOS 16, *) -class ConversationsTests: XCTestCase { - func testCanGetConversationFromIntroEnvelope() async throws { - let fixtures = await fixtures() - let client = fixtures.aliceClient! - - let created = Date() - - let message = try MessageV1.encode( - sender: try fixtures.bobClient.v1keys, - recipient: fixtures.aliceClient.v1keys.toPublicKeyBundle(), - message: try TextCodec().encode(content: "hello", client: client).serializedData(), - timestamp: created - ) - - let envelope = Envelope(topic: .userIntro(client.address), timestamp: created, message: try Message(v1: message).serializedData()) - - let conversation = try await client.conversations.fromIntro(envelope: envelope) - XCTAssertEqual(try conversation.peerAddress, fixtures.bob.address) - XCTAssertEqual(conversation.createdAt.description, created.description) - } - - func testCanGetConversationFromInviteEnvelope() async throws { - let fixtures = await fixtures() - let client: Client = fixtures.aliceClient! - - let created = Date() - - let invitation = try InvitationV1.createDeterministic( - sender: fixtures.bobClient.keys, - recipient: client.keys.getPublicKeyBundle()) - let sealed = try SealedInvitation.createV1( - sender: fixtures.bobClient.keys, - recipient: client.keys.getPublicKeyBundle(), - created: created, - invitation: invitation - ) - - let peerAddress = fixtures.alice.walletAddress - let envelope = Envelope(topic: .userInvite(peerAddress), timestamp: created, message: try sealed.serializedData()) - - let conversation = try await client.conversations.fromInvite(envelope: envelope) - XCTAssertEqual(try conversation.peerAddress, fixtures.bob.address) - XCTAssertEqual(conversation.createdAt.description, created.description) - } - - func testStreamAllMessagesGetsMessageFromKnownConversation() async throws { - let fixtures = await fixtures() - let client = fixtures.aliceClient! - let bobConversation = try await fixtures.bobClient.conversations.newConversation(with: client.address) - - let expectation1 = expectation(description: "got a message") - - Task(priority: .userInitiated) { - for try await _ in try await client.conversations.streamAllMessages() { - expectation1.fulfill() - } - } - - try await Task.sleep(for: .milliseconds(500)) - _ = try await bobConversation.send(text: "hi") - - await waitForExpectations(timeout: 3) - } - - func testCanValidateTopicsInsideConversation() async throws { - let validId = "sdfsadf095b97a9284dcd82b2274856ccac8a21de57bebe34e7f9eeb855fb21126d3b8f" - - // Creation of all known types of topics - let privateStore = Topic.userPrivateStoreKeyBundle(validId).description - let contact = Topic.contact(validId).description - let userIntro = Topic.userIntro(validId).description - let userInvite = Topic.userInvite(validId).description - let directMessageV1 = Topic.directMessageV1(validId, "sd").description - let directMessageV2 = Topic.directMessageV2(validId).description - let preferenceList = Topic.preferenceList(validId).description - - // check if validation of topics accepts all types - XCTAssertTrue(Topic.isValidTopic(topic: privateStore)) - XCTAssertTrue(Topic.isValidTopic(topic: contact)) - XCTAssertTrue(Topic.isValidTopic(topic: userIntro)) - XCTAssertTrue(Topic.isValidTopic(topic: userInvite)) - XCTAssertTrue(Topic.isValidTopic(topic: directMessageV1)) - XCTAssertTrue(Topic.isValidTopic(topic: directMessageV2)) - XCTAssertTrue(Topic.isValidTopic(topic: preferenceList)) - } - - func testCannotValidateTopicsInsideConversation() async throws { - let invalidId = "��\\u0005�!\\u000b���5\\u00001\\u0007�蛨\\u001f\\u00172��.����K9K`�" - - // Creation of all known types of topics - let privateStore = Topic.userPrivateStoreKeyBundle(invalidId).description - let contact = Topic.contact(invalidId).description - let userIntro = Topic.userIntro(invalidId).description - let userInvite = Topic.userInvite(invalidId).description - let directMessageV1 = Topic.directMessageV1(invalidId, "sd").description - let directMessageV2 = Topic.directMessageV2(invalidId).description - let preferenceList = Topic.preferenceList(invalidId).description - - // check if validation of topics declines all types - XCTAssertFalse(Topic.isValidTopic(topic: privateStore)) - XCTAssertFalse(Topic.isValidTopic(topic: contact)) - XCTAssertFalse(Topic.isValidTopic(topic: userIntro)) - XCTAssertFalse(Topic.isValidTopic(topic: userInvite)) - XCTAssertFalse(Topic.isValidTopic(topic: directMessageV1)) - XCTAssertFalse(Topic.isValidTopic(topic: directMessageV2)) - XCTAssertFalse(Topic.isValidTopic(topic: preferenceList)) - } - - func testReturnsAllHMACKeys() async throws { - let alix = try PrivateKey.generate() - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let alixClient = try await Client.create( - account: alix, - options: opts - ) - var conversations: [Conversation] = [] - for _ in 0..<5 { - let account = try PrivateKey.generate() - let client = try await Client.create(account: account, options: opts) - do { - let newConversation = try await alixClient.conversations.newConversation( - with: client.address, - context: InvitationV1.Context(conversationID: "hi") - ) - conversations.append(newConversation) - } catch { - print("Error creating conversation: \(error)") - } - } - - let thirtyDayPeriodsSinceEpoch = Int(Date().timeIntervalSince1970) / (60 * 60 * 24 * 30) - - let hmacKeys = await alixClient.conversations.getHmacKeys() - - let topics = hmacKeys.hmacKeys.keys - conversations.forEach { conversation in - XCTAssertTrue(topics.contains(conversation.topic)) - } - - var topicHmacs: [String: Data] = [:] - let headerBytes = try Crypto.secureRandomBytes(count: 10) - - for conversation in conversations { - let topic = conversation.topic - let payload = try? TextCodec().encode(content: "Hello, world!", client: alixClient) - - _ = try await MessageV2.encode( - client: alixClient, - content: payload!, - topic: topic, - keyMaterial: headerBytes, - codec: TextCodec() - ) - - let keyMaterial = conversation.keyMaterial - let info = "\(thirtyDayPeriodsSinceEpoch)-\(alixClient.address)" - let key = try Crypto.deriveKey(secret: keyMaterial!, nonce: Data(), info: Data(info.utf8)) - let hmac = try Crypto.calculateMac(headerBytes, key) - - topicHmacs[topic] = hmac - } - - for (topic, hmacData) in hmacKeys.hmacKeys { - for (idx, hmacKeyThirtyDayPeriod) in hmacData.values.enumerated() { - let valid = Crypto.verifyHmacSignature( - key: SymmetricKey(data: hmacKeyThirtyDayPeriod.hmacKey), - signature: topicHmacs[topic]!, - message: headerBytes - ) - - XCTAssertTrue(valid == (idx == 1)) - } - } - } - - func testSendConversationWithConsentSignature() async throws { - let fixtures = await fixtures() - - let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) - let signatureText = Signature.consentProofText(peerAddress: fixtures.bobClient.address, timestamp: timestamp) - let signature = try await fixtures.alice.sign(message: signatureText) - - let hex = signature.rawData.toHex - var consentProofPayload = ConsentProofPayload() - consentProofPayload.signature = hex - consentProofPayload.timestamp = timestamp - consentProofPayload.payloadVersion = .consentProofPayloadVersion1 - let boConversation = - try await fixtures.bobClient.conversations.newConversation(with: fixtures.aliceClient.address, context: nil, consentProofPayload: consentProofPayload) - let alixConversations = try await - fixtures.aliceClient.conversations.list() - let alixConversation = alixConversations.first(where: { $0.topic == boConversation.topic }) - XCTAssertNotNil(alixConversation) - let consentStatus = try await fixtures.aliceClient.contacts.isAllowed(fixtures.bobClient.address) - XCTAssertTrue(consentStatus) - } - - func testNetworkConsentOverConsentProof() async throws { - let fixtures = await fixtures() - - let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) - let signatureText = Signature.consentProofText(peerAddress: fixtures.bobClient.address, timestamp: timestamp) - let signature = try await fixtures.alice.sign(message: signatureText) - let hex = signature.rawData.toHex - var consentProofPayload = ConsentProofPayload() - consentProofPayload.signature = hex - consentProofPayload.timestamp = timestamp - consentProofPayload.payloadVersion = .consentProofPayloadVersion1 - let boConversation = - try await fixtures.bobClient.conversations.newConversation(with: fixtures.aliceClient.address, context: nil, consentProofPayload: consentProofPayload) - try await fixtures.aliceClient.contacts.deny(addresses: [fixtures.bobClient.address]) - let alixConversations = try await - fixtures.aliceClient.conversations.list() - let alixConversation = alixConversations.first(where: { $0.topic == boConversation.topic }) - XCTAssertNotNil(alixConversation) - let isDenied = try await fixtures.aliceClient.contacts.isDenied(fixtures.bobClient.address) - XCTAssertTrue(isDenied) - } - - func testConsentProofInvalidSignature() async throws { - throw XCTSkip("this test is flakey in CI, TODO: figure it out") - let fixtures = await fixtures() - - let timestamp = UInt64(Date().timeIntervalSince1970 * 1000) - let signatureText = Signature.consentProofText(peerAddress: fixtures.bobClient.address, timestamp: timestamp + 1) - let signature = try await fixtures.alice.sign(message:signatureText) - let hex = signature.rawData.toHex - var consentProofPayload = ConsentProofPayload() - consentProofPayload.signature = hex - consentProofPayload.timestamp = timestamp - consentProofPayload.payloadVersion = .consentProofPayloadVersion1 - let boConversation = - try await fixtures.bobClient.conversations.newConversation(with: fixtures.aliceClient.address, context: nil, consentProofPayload: consentProofPayload) - let alixConversations = try await - fixtures.aliceClient.conversations.list() - let alixConversation = alixConversations.first(where: { $0.topic == boConversation.topic }) - XCTAssertNotNil(alixConversation) - let isAllowed = try await fixtures.aliceClient.contacts.isAllowed(fixtures.bobClient.address) - XCTAssertFalse(isAllowed) - } -} diff --git a/Tests/XMTPTests/CryptoTests.swift b/Tests/XMTPTests/CryptoTests.swift index 7f4e03cd..c961d051 100644 --- a/Tests/XMTPTests/CryptoTests.swift +++ b/Tests/XMTPTests/CryptoTests.swift @@ -1,12 +1,6 @@ -// -// CryptoTests.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import secp256k1 import XCTest +import secp256k1 + @testable import XMTPiOS final class CryptoTests: XCTestCase { @@ -21,70 +15,50 @@ final class CryptoTests: XCTestCase { func testDecryptingKnownCypherText() throws { let message = Data([5, 5, 5]) let secret = Data([1, 2, 3, 4]) - let encrypted = try CipherText(serializedData: Data([ - // This was generated using xmtp-js code for encrypt(). - 10, 69, 10, 32, 23, 10, 217, 190, 235, 216, 145, - 38, 49, 224, 165, 169, 22, 55, 152, 150, 176, 65, - 207, 91, 45, 45, 16, 171, 146, 125, 143, 60, 152, - 128, 0, 120, 18, 12, 219, 247, 207, 184, 141, 179, - 171, 100, 251, 171, 120, 137, 26, 19, 216, 215, 152, - 167, 118, 59, 93, 177, 53, 242, 147, 10, 87, 143, - 27, 245, 154, 169, 109, - ])) + let encrypted = try CipherText( + serializedData: Data([ + // This was generated using xmtp-js code for encrypt(). + 10, 69, 10, 32, 23, 10, 217, 190, 235, 216, 145, + 38, 49, 224, 165, 169, 22, 55, 152, 150, 176, 65, + 207, 91, 45, 45, 16, 171, 146, 125, 143, 60, 152, + 128, 0, 120, 18, 12, 219, 247, 207, 184, 141, 179, + 171, 100, 251, 171, 120, 137, 26, 19, 216, 215, 152, + 167, 118, 59, 93, 177, 53, 242, 147, 10, 87, 143, + 27, 245, 154, 169, 109, + ])) let decrypted = try Crypto.decrypt(secret, encrypted) XCTAssertEqual(message, decrypted) } - func testMessages() async throws { - let aliceWallet = try PrivateKey.generate() - let bobWallet = try PrivateKey.generate() - - let alice = try await PrivateKeyBundleV1.generate(wallet: aliceWallet) - let bob = try await PrivateKeyBundleV1.generate(wallet: bobWallet) - - let msg = "Hello world" - let decrypted = Data(msg.utf8) - - let alicePublic = alice.toPublicKeyBundle() - let bobPublic = bob.toPublicKeyBundle() - - let aliceSecret = try alice.sharedSecret(peer: bobPublic, myPreKey: alicePublic.preKey, isRecipient: false) - - let encrypted = try Crypto.encrypt(aliceSecret, decrypted) - - let bobSecret = try bob.sharedSecret(peer: alicePublic, myPreKey: bobPublic.preKey, isRecipient: true) - let bobDecrypted = try Crypto.decrypt(bobSecret, encrypted) - - let decryptedText = String(data: bobDecrypted, encoding: .utf8) - - XCTAssertEqual(decryptedText, msg) - } - func testGenerateAndValidateHmac() async throws { let secret = try Crypto.secureRandomBytes(count: 32) let info = try Crypto.secureRandomBytes(count: 32) let message = try Crypto.secureRandomBytes(count: 32) - let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let hmac = try Crypto.generateHmacSignature( + secret: secret, info: info, message: message) let key = try Crypto.hkdfHmacKey(secret: secret, info: info) - let valid = Crypto.verifyHmacSignature(key: key, signature: hmac, message: message) - + let valid = Crypto.verifyHmacSignature( + key: key, signature: hmac, message: message) + XCTAssertTrue(valid) } - + func testGenerateAndValidateHmacWithExportedKey() async throws { let secret = try Crypto.secureRandomBytes(count: 32) let info = try Crypto.secureRandomBytes(count: 32) let message = try Crypto.secureRandomBytes(count: 32) - let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let hmac = try Crypto.generateHmacSignature( + secret: secret, info: info, message: message) let key = try Crypto.hkdfHmacKey(secret: secret, info: info) let exportedKey = Crypto.exportHmacKey(key: key) let importedKey = Crypto.importHmacKey(keyData: exportedKey) - let valid = Crypto.verifyHmacSignature(key: importedKey, signature: hmac, message: message) - + let valid = Crypto.verifyHmacSignature( + key: importedKey, signature: hmac, message: message) + XCTAssertTrue(valid) } - + func testGenerateDifferentHmacKeysWithDifferentInfos() async throws { let secret = try Crypto.secureRandomBytes(count: 32) let info1 = try Crypto.secureRandomBytes(count: 32) @@ -93,30 +67,32 @@ final class CryptoTests: XCTestCase { let key2 = try Crypto.hkdfHmacKey(secret: secret, info: info2) let exportedKey1 = Crypto.exportHmacKey(key: key1) let exportedKey2 = Crypto.exportHmacKey(key: key2) - + XCTAssertNotEqual(exportedKey1, exportedKey2) } - + func testValidateHmacWithWrongMessage() async throws { let secret = try Crypto.secureRandomBytes(count: 32) let info = try Crypto.secureRandomBytes(count: 32) let message = try Crypto.secureRandomBytes(count: 32) - let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let hmac = try Crypto.generateHmacSignature( + secret: secret, info: info, message: message) let key = try Crypto.hkdfHmacKey(secret: secret, info: info) let valid = Crypto.verifyHmacSignature( key: key, signature: hmac, message: try Crypto.secureRandomBytes(count: 32) ) - + XCTAssertFalse(valid) } - + func testValidateHmacWithWrongKey() async throws { let secret = try Crypto.secureRandomBytes(count: 32) let info = try Crypto.secureRandomBytes(count: 32) let message = try Crypto.secureRandomBytes(count: 32) - let hmac = try Crypto.generateHmacSignature(secret: secret, info: info, message: message) + let hmac = try Crypto.generateHmacSignature( + secret: secret, info: info, message: message) let valid = Crypto.verifyHmacSignature( key: try Crypto.hkdfHmacKey( secret: try Crypto.secureRandomBytes(count: 32), @@ -124,7 +100,7 @@ final class CryptoTests: XCTestCase { signature: hmac, message: message ) - + XCTAssertFalse(valid) } } diff --git a/Tests/XMTPTests/DmTests.swift b/Tests/XMTPTests/DmTests.swift index 443352b4..81adc6e7 100644 --- a/Tests/XMTPTests/DmTests.swift +++ b/Tests/XMTPTests/DmTests.swift @@ -1,126 +1,75 @@ -// -// DmTests.swift -// XMTPiOS -// -// Created by Naomi Plasterer on 10/23/24. -// - import CryptoKit -import XCTest -@testable import XMTPiOS import LibXMTP +import XCTest import XMTPTestHelpers +@testable import XMTPiOS + @available(iOS 16, *) class DmTests: XCTestCase { - struct LocalFixtures { - var alix: PrivateKey! - var bo: PrivateKey! - var caro: PrivateKey! - var alixClient: Client! - var boClient: Client! - var caroClient: Client! - } - - func localFixtures() async throws -> LocalFixtures { - let key = try Crypto.secureRandomBytes(count: 32) - let alix = try PrivateKey.generate() - let alixClient = try await Client.createV3( - account: alix, - options: .init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true, - encryptionKey: key - ) - ) - let bo = try PrivateKey.generate() - let boClient = try await Client.createV3( - account: bo, - options: .init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true, - encryptionKey: key - ) - ) - let caro = try PrivateKey.generate() - let caroClient = try await Client.createV3( - account: caro, - options: .init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true, - encryptionKey: key - ) - ) - - return .init( - alix: alix, - bo: bo, - caro: caro, - alixClient: alixClient, - boClient: boClient, - caroClient: caroClient - ) - } - + func testCanCreateADm() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let convo1 = try await fixtures.boClient.conversations.findOrCreateDm(with: fixtures.alix.walletAddress) + let convo1 = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) try await fixtures.alixClient.conversations.sync() - let sameConvo1 = try await fixtures.alixClient.conversations.findOrCreateDm(with: fixtures.bo.walletAddress) + let sameConvo1 = try await fixtures.alixClient.conversations + .findOrCreateDm(with: fixtures.bo.walletAddress) XCTAssertEqual(convo1.id, sameConvo1.id) } func testCanListDmMembers() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let dm = try await fixtures.boClient.conversations.findOrCreateDm(with: fixtures.alix.walletAddress) - var members = try await dm.members + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) + let members = try await dm.members XCTAssertEqual(members.count, 2) - let peer = try await dm.peerInboxId + let peer = try dm.peerInboxId XCTAssertEqual(peer, fixtures.alixClient.inboxID) } func testCannotStartGroupWithSelf() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() await assertThrowsAsyncError( - try await fixtures.alixClient.conversations.findOrCreateDm(with: fixtures.alix.address) + try await fixtures.alixClient.conversations.findOrCreateDm( + with: fixtures.alix.address) ) } func testCannotStartGroupWithNonRegisteredIdentity() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() let nonRegistered = try PrivateKey.generate() await assertThrowsAsyncError( - try await fixtures.alixClient.conversations.findOrCreateDm(with: nonRegistered.address) + try await fixtures.alixClient.conversations.findOrCreateDm( + with: nonRegistered.address) ) } func testDmStartsWithAllowedState() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let dm = try await fixtures.boClient.conversations.findOrCreateDm(with: fixtures.alix.walletAddress) + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) _ = try await dm.send(content: "howdy") _ = try await dm.send(content: "gm") try await dm.sync() - let isAllowed = try await fixtures.boClient.contacts.isGroupAllowed(groupId: dm.id) - let dmState = try await fixtures.boClient.contacts.consentList.groupState(groupId: dm.id) - XCTAssertTrue(isAllowed) + let dmState = try await fixtures.boClient.preferences.consentList + .conversationState(conversationId: dm.id) XCTAssertEqual(dmState, .allowed) XCTAssertEqual(try dm.consentState(), .allowed) } func testCanSendMessageToDm() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let dm = try await fixtures.boClient.conversations.findOrCreateDm(with: fixtures.alix.walletAddress) + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) _ = try await dm.send(content: "howdy") let messageId = try await dm.send(content: "gm") try await dm.sync() @@ -133,7 +82,7 @@ class DmTests: XCTestCase { XCTAssertEqual(messages.count, 3) try await fixtures.alixClient.conversations.sync() - let sameDm = try await fixtures.alixClient.conversations.dms().last! + let sameDm = try await fixtures.alixClient.conversations.listDms().last! try await sameDm.sync() let sameMessages = try await sameDm.messages() @@ -142,14 +91,15 @@ class DmTests: XCTestCase { } func testCanStreamDmMessages() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let dm = try await fixtures.boClient.conversations.findOrCreateDm(with: fixtures.alix.walletAddress) + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) try await fixtures.alixClient.conversations.sync() - + let expectation1 = XCTestExpectation(description: "got a message") expectation1.expectedFulfillmentCount = 1 - + Task(priority: .userInitiated) { for try await _ in dm.streamMessages() { expectation1.fulfill() @@ -157,49 +107,62 @@ class DmTests: XCTestCase { } _ = try await dm.send(content: "hi") - + await fulfillment(of: [expectation1], timeout: 3) } func testCanStreamAllDecryptedDmMessages() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let dm = try await fixtures.boClient.conversations.findOrCreateDm(with: fixtures.alix.walletAddress) + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) try await fixtures.alixClient.conversations.sync() - + let expectation1 = XCTestExpectation(description: "got a message") expectation1.expectedFulfillmentCount = 2 - + Task(priority: .userInitiated) { - for try await _ in await fixtures.alixClient.conversations.streamAllConversationMessages() { + for try await _ in await fixtures.alixClient.conversations + .streamAllMessages() + { expectation1.fulfill() } } _ = try await dm.send(content: "hi") - let caroDm = try await fixtures.caroClient.conversations.findOrCreateDm(with: fixtures.alixClient.address) + let caroDm = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.alixClient.address) _ = try await caroDm.send(content: "hi") - + await fulfillment(of: [expectation1], timeout: 3) } func testDmConsent() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let dm = try await fixtures.boClient.conversations.findOrCreateDm(with: fixtures.alix.walletAddress) + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) - let isGroup = try await fixtures.boClient.contacts.isGroupAllowed(groupId: dm.id) - XCTAssertTrue(isGroup) + let isDm = try await fixtures.boClient.preferences.consentList + .conversationState(conversationId: dm.id) + XCTAssertEqual(isDm, .allowed) XCTAssertEqual(try dm.consentState(), .allowed) - try await fixtures.boClient.contacts.denyGroups(groupIds: [dm.id]) - let isDenied = try await fixtures.boClient.contacts.isGroupDenied(groupId: dm.id) - XCTAssertTrue(isDenied) + try await fixtures.boClient.preferences.consentList.setConsentState( + entries: [ + ConsentListEntry( + value: dm.id, entryType: .conversation_id, + consentType: .denied) + ]) + let isDenied = try await fixtures.boClient.preferences.consentList + .conversationState(conversationId: dm.id) + XCTAssertEqual(isDenied, .denied) XCTAssertEqual(try dm.consentState(), .denied) try await dm.updateConsentState(state: .allowed) - let isAllowed = try await fixtures.boClient.contacts.isGroupAllowed(groupId: dm.id) - XCTAssertTrue(isAllowed) + let isAllowed = try await fixtures.boClient.preferences.consentList + .conversationState(conversationId: dm.id) + XCTAssertEqual(isAllowed, .allowed) XCTAssertEqual(try dm.consentState(), .allowed) } } diff --git a/Tests/XMTPTests/FramesTests.swift b/Tests/XMTPTests/FramesTests.swift deleted file mode 100644 index 94828061..00000000 --- a/Tests/XMTPTests/FramesTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// FramesTests.swift -// -// -// Created by Alex Risch on 4/1/24. -// - -import Foundation -import secp256k1 -import XCTest -@testable import XMTPiOS - -final class FramesTests: XCTestCase { - func testInstantiateFramesClient() async throws { - let frameUrl = "https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8" - - let key = try Crypto.secureRandomBytes(count: 32) - let bo = try PrivateKey.generate() - let client = try await Client.create( - account: bo, - options: .init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) - ) - - let framesClient = FramesClient(xmtpClient: client) - let metadata = try await framesClient.proxy.readMetadata(url: frameUrl) - let conversationTopic = "foo" - let participantAccountAddresses = ["amal", "bola"] - let dmInputs = DmActionInputs( - conversationTopic: conversationTopic, participantAccountAddresses: participantAccountAddresses) - let conversationInputs = ConversationActionInputs.dm(dmInputs) - let frameInputs = FrameActionInputs(frameUrl: frameUrl, buttonIndex: 1, inputText: nil, state: nil, conversationInputs: conversationInputs) - let signedPayload = try await framesClient.signFrameAction(inputs: frameInputs) - - guard let postUrl = metadata.extractedTags["fc:frame:post_url"] else { - throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "postUrl should exist"]) - } - let response = try await framesClient.proxy.post(url: postUrl, payload: signedPayload) - - guard response.extractedTags["fc:frame"] == "vNext" else { - throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "response should have expected extractedTags"]) - } - - guard let imageUrl = response.extractedTags["fc:frame:image"] else { - throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "imageUrl should exist"]) - } - - let mediaUrl = try await framesClient.proxy.mediaUrl(url: imageUrl) - - let (_, mediaResponse) = try await URLSession.shared.data(from: URL(string: mediaUrl)!) - - guard (mediaResponse as? HTTPURLResponse)?.statusCode == 200 else { - throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "downloadedMedia should be ok"]) - } - - guard (mediaResponse as? HTTPURLResponse)?.mimeType == "image/png" else { - throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "downloadedMedia should be image/png"]) - } - } -} diff --git a/Tests/XMTPTests/GroupPermissionsTests.swift b/Tests/XMTPTests/GroupPermissionsTests.swift index 8f362d6f..f650a197 100644 --- a/Tests/XMTPTests/GroupPermissionsTests.swift +++ b/Tests/XMTPTests/GroupPermissionsTests.swift @@ -1,400 +1,415 @@ -// -// GroupPermissionTests.swift -// -// -// Created by Cameron Voell on 5/29/24. -// - import CryptoKit -import XCTest -import XMTPiOS import LibXMTP +import XCTest import XMTPTestHelpers +import XMTPiOS @available(iOS 16, *) class GroupPermissionTests: XCTestCase { - // Use these fixtures to talk to the local node - struct LocalFixtures { - public var alice: PrivateKey! - public var bob: PrivateKey! - public var caro: PrivateKey! - public var aliceClient: Client! - public var bobClient: Client! - public var caroClient: Client! - } - - enum CryptoError: Error { - case randomBytes, combinedPayload, hmacSignatureError - } - - public func secureRandomBytes(count: Int) throws -> Data { - var bytes = [UInt8](repeating: 0, count: count) - - // Fill bytes with secure random data - let status = SecRandomCopyBytes( - kSecRandomDefault, - count, - &bytes - ) - - // A status of errSecSuccess indicates success - if status == errSecSuccess { - return Data(bytes) - } else { - throw CryptoError.randomBytes - } - } - - func localFixtures() async throws -> LocalFixtures { - let key = try secureRandomBytes(count: 32) - let alice = try PrivateKey.generate() - let aliceClient = try await Client.create( - account: alice, - options: .init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true, - encryptionKey: key - ) - ) - let bob = try PrivateKey.generate() - let bobClient = try await Client.create( - account: bob, - options: .init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true, - encryptionKey: key - ) - ) - let caro = try PrivateKey.generate() - let caroClient = try await Client.create( - account: caro, - options: .init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true, - encryptionKey: key - ) - ) - - return .init( - alice: alice, - bob: bob, - caro: caro, - aliceClient: aliceClient, - bobClient: bobClient, - caroClient: caroClient - ) - } - - func testGroupCreatedWithCorrectAdminList() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - XCTAssertFalse(try bobGroup.isAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssertTrue(try bobGroup.isSuperAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssertFalse(try aliceGroup.isCreator()) - XCTAssertFalse(try aliceGroup.isAdmin(inboxId: fixtures.aliceClient.inboxID)) - XCTAssertFalse(try aliceGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) - - let adminList = try bobGroup.listAdmins() - let superAdminList = try bobGroup.listSuperAdmins() - - XCTAssertEqual(adminList.count, 0) - XCTAssertFalse(adminList.contains(fixtures.bobClient.inboxID)) - XCTAssertEqual(superAdminList.count, 1) - XCTAssertTrue(superAdminList.contains(fixtures.bobClient.inboxID)) - } - - func testGroupCanUpdateAdminList() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address, fixtures.caro.address], permissions: .adminOnly) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - XCTAssertFalse(try bobGroup.isAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssertTrue(try bobGroup.isSuperAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssertFalse(try aliceGroup.isCreator()) - XCTAssertFalse(try aliceGroup.isAdmin(inboxId: fixtures.aliceClient.inboxID)) - XCTAssertFalse(try aliceGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) - - var adminList = try bobGroup.listAdmins() - var superAdminList = try bobGroup.listSuperAdmins() - XCTAssertEqual(adminList.count, 0) - XCTAssertFalse(adminList.contains(fixtures.bobClient.inboxID)) - XCTAssertEqual(superAdminList.count, 1) - XCTAssertTrue(superAdminList.contains(fixtures.bobClient.inboxID)) - - // Verify that alice can NOT update group name - XCTAssertEqual(try bobGroup.groupName(), "") - await assertThrowsAsyncError( - try await aliceGroup.updateGroupName(groupName: "Alice group name") - ) - - try await aliceGroup.sync() - try await bobGroup.sync() - XCTAssertEqual(try bobGroup.groupName(), "") - XCTAssertEqual(try aliceGroup.groupName(), "") - - try await bobGroup.addAdmin(inboxId: fixtures.aliceClient.inboxID) - try await bobGroup.sync() - try await aliceGroup.sync() - - adminList = try bobGroup.listAdmins() - superAdminList = try bobGroup.listSuperAdmins() - - XCTAssertTrue(try aliceGroup.isAdmin(inboxId: fixtures.aliceClient.inboxID)) - XCTAssertEqual(adminList.count, 1) - XCTAssertTrue(adminList.contains(fixtures.aliceClient.inboxID)) - XCTAssertEqual(superAdminList.count, 1) - - // Verify that alice can now update group name - try await aliceGroup.updateGroupName(groupName: "Alice group name") - try await aliceGroup.sync() - try await bobGroup.sync() - XCTAssertEqual(try bobGroup.groupName(), "Alice group name") - XCTAssertEqual(try aliceGroup.groupName(), "Alice group name") - - try await bobGroup.removeAdmin(inboxId: fixtures.aliceClient.inboxID) - try await bobGroup.sync() - try await aliceGroup.sync() - - adminList = try bobGroup.listAdmins() - superAdminList = try bobGroup.listSuperAdmins() - - XCTAssertFalse(try aliceGroup.isAdmin(inboxId: fixtures.aliceClient.inboxID)) - XCTAssertEqual(adminList.count, 0) - XCTAssertFalse(adminList.contains(fixtures.aliceClient.inboxID)) - XCTAssertEqual(superAdminList.count, 1) - - // Verify that alice can NOT update group name - await assertThrowsAsyncError( - try await aliceGroup.updateGroupName(groupName: "Alice group name 2") - ) - } - - func testGroupCanUpdateSuperAdminList() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address, fixtures.caro.address], permissions: .adminOnly) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - XCTAssertTrue(try bobGroup.isSuperAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssertFalse(try aliceGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) - - // Attempt to remove bob as a super admin by alice should fail since she is not a super admin - await assertThrowsAsyncError( - try await aliceGroup.removeSuperAdmin(inboxId: fixtures.bobClient.inboxID) - ) - - // Make alice a super admin - try await bobGroup.addSuperAdmin(inboxId: fixtures.aliceClient.inboxID) - try await bobGroup.sync() - try await aliceGroup.sync() - XCTAssertTrue(try aliceGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) - - // Now alice should be able to remove bob as a super admin - try await aliceGroup.removeSuperAdmin(inboxId: fixtures.bobClient.inboxID) - try await aliceGroup.sync() - try await bobGroup.sync() - - let superAdminList = try bobGroup.listSuperAdmins() - XCTAssertFalse(superAdminList.contains(fixtures.bobClient.inboxID)) - XCTAssertTrue(superAdminList.contains(fixtures.aliceClient.inboxID)) - } - - func testGroupMembersAndPermissionLevel() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address, fixtures.caro.address], permissions: .adminOnly) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - // Initial checks for group members and their permissions - var members = try await bobGroup.members - var admins = members.filter { $0.permissionLevel == PermissionLevel.Admin } - var superAdmins = members.filter { $0.permissionLevel == PermissionLevel.SuperAdmin } - var regularMembers = members.filter { $0.permissionLevel == PermissionLevel.Member } - - XCTAssertEqual(admins.count, 0) - XCTAssertEqual(superAdmins.count, 1) - XCTAssertEqual(regularMembers.count, 2) - - // Add alice as an admin - try await bobGroup.addAdmin(inboxId: fixtures.aliceClient.inboxID) - try await bobGroup.sync() - try await aliceGroup.sync() - - members = try await bobGroup.members - admins = members.filter { $0.permissionLevel == PermissionLevel.Admin } - superAdmins = members.filter { $0.permissionLevel == PermissionLevel.SuperAdmin } - regularMembers = members.filter { $0.permissionLevel == PermissionLevel.Member } - - XCTAssertEqual(admins.count, 1) - XCTAssertEqual(superAdmins.count, 1) - XCTAssertEqual(regularMembers.count, 1) - - // Add caro as a super admin - try await bobGroup.addSuperAdmin(inboxId: fixtures.caroClient.inboxID) - try await bobGroup.sync() - try await aliceGroup.sync() - - members = try await bobGroup.members - admins = members.filter { $0.permissionLevel == PermissionLevel.Admin } - superAdmins = members.filter { $0.permissionLevel == PermissionLevel.SuperAdmin } - regularMembers = members.filter { $0.permissionLevel == PermissionLevel.Member } - - XCTAssertEqual(admins.count, 1) - XCTAssertEqual(superAdmins.count, 2) - XCTAssertTrue(regularMembers.isEmpty) - } - - func testCanCommitAfterInvalidPermissionsCommit() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address, fixtures.caro.address], permissions: .allMembers) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - // Verify that alice can NOT add an admin - XCTAssertEqual(try bobGroup.groupName(), "") - await assertThrowsAsyncError( - try await aliceGroup.addAdmin(inboxId: fixtures.aliceClient.inboxID) - ) - - try await aliceGroup.sync() - try await bobGroup.sync() - - // Verify that alice can update group name - try await bobGroup.sync() - try await aliceGroup.sync() - try await aliceGroup.updateGroupName(groupName: "Alice group name") - try await aliceGroup.sync() - try await bobGroup.sync() - - XCTAssertEqual(try bobGroup.groupName(), "Alice group name") - XCTAssertEqual(try aliceGroup.groupName(), "Alice group name") - } - - func testCanUpdatePermissions() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup( - with: [fixtures.alice.address, fixtures.caro.address], - permissions: .adminOnly - ) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - // Verify that Alice cannot update group description - XCTAssertEqual(try bobGroup.groupDescription(), "") - await assertThrowsAsyncError( - try await aliceGroup.updateGroupDescription(groupDescription: "new group description") - ) - - try await aliceGroup.sync() - try await bobGroup.sync() - XCTAssertEqual(try bobGroup.permissionPolicySet().updateGroupDescriptionPolicy, .admin) - - // Update group description permissions so Alice can update - try await bobGroup.updateGroupDescriptionPermission(newPermissionOption: .allow) - try await bobGroup.sync() - try await aliceGroup.sync() - XCTAssertEqual(try bobGroup.permissionPolicySet().updateGroupDescriptionPolicy, .allow) - - // Verify that Alice can now update group description - try await aliceGroup.updateGroupDescription(groupDescription: "Alice group description") - try await aliceGroup.sync() - try await bobGroup.sync() - XCTAssertEqual(try bobGroup.groupDescription(), "Alice group description") - XCTAssertEqual(try aliceGroup.groupDescription(), "Alice group description") - } - - func testCanUpdatePinnedFrameUrl() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup( - with: [fixtures.alice.address, fixtures.caro.address], - permissions: .adminOnly, - pinnedFrameUrl: "initial url" - ) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - // Verify that Alice cannot update group pinned frame url - XCTAssertEqual(try bobGroup.groupPinnedFrameUrl(), "initial url") - await assertThrowsAsyncError( - try await aliceGroup.updateGroupPinnedFrameUrl(groupPinnedFrameUrl: "https://foo/bar.com") - ) - - try await aliceGroup.sync() - try await bobGroup.sync() - XCTAssertEqual(try bobGroup.permissionPolicySet().updateGroupPinnedFrameUrlPolicy, .admin) - - // Update group pinned frame url permissions so Alice can update - try await bobGroup.updateGroupPinnedFrameUrlPermission(newPermissionOption: .allow) - try await bobGroup.sync() - try await aliceGroup.sync() - XCTAssertEqual(try bobGroup.permissionPolicySet().updateGroupPinnedFrameUrlPolicy, .allow) - - // Verify that Alice can now update group pinned frame url - try await aliceGroup.updateGroupPinnedFrameUrl(groupPinnedFrameUrl: "https://foo/barz.com") - try await aliceGroup.sync() - try await bobGroup.sync() - XCTAssertEqual(try bobGroup.groupPinnedFrameUrl(), "https://foo/barz.com") - XCTAssertEqual(try aliceGroup.groupPinnedFrameUrl(), "https://foo/barz.com") - } - - func testCanCreateGroupWithCustomPermissions() async throws { - let fixtures = try await localFixtures() - let permissionPolicySet = PermissionPolicySet( - addMemberPolicy: PermissionOption.admin, - removeMemberPolicy: PermissionOption.deny, - addAdminPolicy: PermissionOption.admin, - removeAdminPolicy: PermissionOption.superAdmin, - updateGroupNamePolicy: PermissionOption.admin, - updateGroupDescriptionPolicy: PermissionOption.allow, - updateGroupImagePolicy: PermissionOption.admin, - updateGroupPinnedFrameUrlPolicy: PermissionOption.deny - ) - let _bobGroup = try await fixtures.bobClient.conversations.newGroupCustomPermissions( - with: [fixtures.alice.address, fixtures.caro.address], - permissionPolicySet: permissionPolicySet, - pinnedFrameUrl: "initial url" - ) - - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - - let alicePermissionSet = try aliceGroup.permissionPolicySet() - XCTAssert(alicePermissionSet.addMemberPolicy == PermissionOption.admin) - XCTAssert(alicePermissionSet.removeMemberPolicy == PermissionOption.deny) - XCTAssert(alicePermissionSet.addAdminPolicy == PermissionOption.admin) - XCTAssert(alicePermissionSet.removeAdminPolicy == PermissionOption.superAdmin) - XCTAssert(alicePermissionSet.updateGroupNamePolicy == PermissionOption.admin) - XCTAssert(alicePermissionSet.updateGroupDescriptionPolicy == PermissionOption.allow) - XCTAssert(alicePermissionSet.updateGroupImagePolicy == PermissionOption.admin) - XCTAssert(alicePermissionSet.updateGroupPinnedFrameUrlPolicy == PermissionOption.deny) - } - - func testCreateGroupWithInvalidPermissionsFails() async throws { - let fixtures = try await localFixtures() - // Add / remove admin can not be set to "allow" - let permissionPolicySetInvalid = PermissionPolicySet( - addMemberPolicy: PermissionOption.admin, - removeMemberPolicy: PermissionOption.deny, - addAdminPolicy: PermissionOption.allow, - removeAdminPolicy: PermissionOption.superAdmin, - updateGroupNamePolicy: PermissionOption.admin, - updateGroupDescriptionPolicy: PermissionOption.allow, - updateGroupImagePolicy: PermissionOption.admin, - updateGroupPinnedFrameUrlPolicy: PermissionOption.deny - ) - await assertThrowsAsyncError( - try await fixtures.bobClient.conversations.newGroupCustomPermissions( - with: [fixtures.alice.address, fixtures.caro.address], - permissionPolicySet: permissionPolicySetInvalid, - pinnedFrameUrl: "initial url" - ) - ) - } + enum CryptoError: Error { + case randomBytes, combinedPayload, hmacSignatureError + } + + public func secureRandomBytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + + // Fill bytes with secure random data + let status = SecRandomCopyBytes( + kSecRandomDefault, + count, + &bytes + ) + + // A status of errSecSuccess indicates success + if status == errSecSuccess { + return Data(bytes) + } else { + throw CryptoError.randomBytes + } + } + + func testGroupCreatedWithCorrectAdminList() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address]) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + XCTAssertFalse( + try boGroup.isAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssertTrue( + try boGroup.isSuperAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssertFalse(try alixGroup.isCreator()) + XCTAssertFalse( + try alixGroup.isAdmin(inboxId: fixtures.alixClient.inboxID)) + XCTAssertFalse( + try alixGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) + + let adminList = try boGroup.listAdmins() + let superAdminList = try boGroup.listSuperAdmins() + + XCTAssertEqual(adminList.count, 0) + XCTAssertFalse(adminList.contains(fixtures.boClient.inboxID)) + XCTAssertEqual(superAdminList.count, 1) + XCTAssertTrue(superAdminList.contains(fixtures.boClient.inboxID)) + } + + func testGroupCanUpdateAdminList() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address, fixtures.caro.address], + permissions: .adminOnly) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + XCTAssertFalse( + try boGroup.isAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssertTrue( + try boGroup.isSuperAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssertFalse(try alixGroup.isCreator()) + XCTAssertFalse( + try alixGroup.isAdmin(inboxId: fixtures.alixClient.inboxID)) + XCTAssertFalse( + try alixGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) + + var adminList = try boGroup.listAdmins() + var superAdminList = try boGroup.listSuperAdmins() + XCTAssertEqual(adminList.count, 0) + XCTAssertFalse(adminList.contains(fixtures.boClient.inboxID)) + XCTAssertEqual(superAdminList.count, 1) + XCTAssertTrue(superAdminList.contains(fixtures.boClient.inboxID)) + + // Verify that alix can NOT update group name + XCTAssertEqual(try boGroup.groupName(), "") + await assertThrowsAsyncError( + try await alixGroup.updateGroupName(groupName: "alix group name") + ) + + try await alixGroup.sync() + try await boGroup.sync() + XCTAssertEqual(try boGroup.groupName(), "") + XCTAssertEqual(try alixGroup.groupName(), "") + + try await boGroup.addAdmin(inboxId: fixtures.alixClient.inboxID) + try await boGroup.sync() + try await alixGroup.sync() + + adminList = try boGroup.listAdmins() + superAdminList = try boGroup.listSuperAdmins() + + XCTAssertTrue( + try alixGroup.isAdmin(inboxId: fixtures.alixClient.inboxID)) + XCTAssertEqual(adminList.count, 1) + XCTAssertTrue(adminList.contains(fixtures.alixClient.inboxID)) + XCTAssertEqual(superAdminList.count, 1) + + // Verify that alix can now update group name + try await alixGroup.updateGroupName(groupName: "alix group name") + try await alixGroup.sync() + try await boGroup.sync() + XCTAssertEqual(try boGroup.groupName(), "alix group name") + XCTAssertEqual(try alixGroup.groupName(), "alix group name") + + try await boGroup.removeAdmin(inboxId: fixtures.alixClient.inboxID) + try await boGroup.sync() + try await alixGroup.sync() + + adminList = try boGroup.listAdmins() + superAdminList = try boGroup.listSuperAdmins() + + XCTAssertFalse( + try alixGroup.isAdmin(inboxId: fixtures.alixClient.inboxID)) + XCTAssertEqual(adminList.count, 0) + XCTAssertFalse(adminList.contains(fixtures.alixClient.inboxID)) + XCTAssertEqual(superAdminList.count, 1) + + // Verify that alix can NOT update group name + await assertThrowsAsyncError( + try await alixGroup.updateGroupName( + groupName: "alix group name 2") + ) + } + + func testGroupCanUpdateSuperAdminList() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address, fixtures.caro.address], + permissions: .adminOnly) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + XCTAssertTrue( + try boGroup.isSuperAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssertFalse( + try alixGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) + + // Attempt to remove bo as a super admin by alix should fail since she is not a super admin + await assertThrowsAsyncError( + try await alixGroup.removeSuperAdmin( + inboxId: fixtures.boClient.inboxID) + ) + + // Make alix a super admin + try await boGroup.addSuperAdmin(inboxId: fixtures.alixClient.inboxID) + try await boGroup.sync() + try await alixGroup.sync() + XCTAssertTrue( + try alixGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) + + // Now alix should be able to remove bo as a super admin + try await alixGroup.removeSuperAdmin( + inboxId: fixtures.boClient.inboxID) + try await alixGroup.sync() + try await boGroup.sync() + + let superAdminList = try boGroup.listSuperAdmins() + XCTAssertFalse(superAdminList.contains(fixtures.boClient.inboxID)) + XCTAssertTrue(superAdminList.contains(fixtures.alixClient.inboxID)) + } + + func testGroupMembersAndPermissionLevel() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address, fixtures.caro.address], + permissions: .adminOnly) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + // Initial checks for group members and their permissions + var members = try await boGroup.members + var admins = members.filter { + $0.permissionLevel == PermissionLevel.Admin + } + var superAdmins = members.filter { + $0.permissionLevel == PermissionLevel.SuperAdmin + } + var regularMembers = members.filter { + $0.permissionLevel == PermissionLevel.Member + } + + XCTAssertEqual(admins.count, 0) + XCTAssertEqual(superAdmins.count, 1) + XCTAssertEqual(regularMembers.count, 2) + + // Add alix as an admin + try await boGroup.addAdmin(inboxId: fixtures.alixClient.inboxID) + try await boGroup.sync() + try await alixGroup.sync() + + members = try await boGroup.members + admins = members.filter { $0.permissionLevel == PermissionLevel.Admin } + superAdmins = members.filter { + $0.permissionLevel == PermissionLevel.SuperAdmin + } + regularMembers = members.filter { + $0.permissionLevel == PermissionLevel.Member + } + + XCTAssertEqual(admins.count, 1) + XCTAssertEqual(superAdmins.count, 1) + XCTAssertEqual(regularMembers.count, 1) + + // Add caro as a super admin + try await boGroup.addSuperAdmin(inboxId: fixtures.caroClient.inboxID) + try await boGroup.sync() + try await alixGroup.sync() + + members = try await boGroup.members + admins = members.filter { $0.permissionLevel == PermissionLevel.Admin } + superAdmins = members.filter { + $0.permissionLevel == PermissionLevel.SuperAdmin + } + regularMembers = members.filter { + $0.permissionLevel == PermissionLevel.Member + } + + XCTAssertEqual(admins.count, 1) + XCTAssertEqual(superAdmins.count, 2) + XCTAssertTrue(regularMembers.isEmpty) + } + + func testCanCommitAfterInvalidPermissionsCommit() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address, fixtures.caro.address], + permissions: .allMembers) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + // Verify that alix can NOT add an admin + XCTAssertEqual(try boGroup.groupName(), "") + await assertThrowsAsyncError( + try await alixGroup.addAdmin(inboxId: fixtures.alixClient.inboxID) + ) + + try await alixGroup.sync() + try await boGroup.sync() + + // Verify that alix can update group name + try await boGroup.sync() + try await alixGroup.sync() + try await alixGroup.updateGroupName(groupName: "alix group name") + try await alixGroup.sync() + try await boGroup.sync() + + XCTAssertEqual(try boGroup.groupName(), "alix group name") + XCTAssertEqual(try alixGroup.groupName(), "alix group name") + } + + func testCanUpdatePermissions() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address, fixtures.caro.address], + permissions: .adminOnly + ) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + // Verify that alix cannot update group description + XCTAssertEqual(try boGroup.groupDescription(), "") + await assertThrowsAsyncError( + try await alixGroup.updateGroupDescription( + groupDescription: "new group description") + ) + + try await alixGroup.sync() + try await boGroup.sync() + XCTAssertEqual( + try boGroup.permissionPolicySet().updateGroupDescriptionPolicy, + .admin) + + // Update group description permissions so alix can update + try await boGroup.updateGroupDescriptionPermission( + newPermissionOption: .allow) + try await boGroup.sync() + try await alixGroup.sync() + XCTAssertEqual( + try boGroup.permissionPolicySet().updateGroupDescriptionPolicy, + .allow) + + // Verify that alix can now update group description + try await alixGroup.updateGroupDescription( + groupDescription: "alix group description") + try await alixGroup.sync() + try await boGroup.sync() + XCTAssertEqual( + try boGroup.groupDescription(), "alix group description") + XCTAssertEqual( + try alixGroup.groupDescription(), "alix group description") + } + + func testCanUpdatePinnedFrameUrl() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address, fixtures.caro.address], + permissions: .adminOnly, + pinnedFrameUrl: "initial url" + ) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + // Verify that alix cannot update group pinned frame url + XCTAssertEqual(try boGroup.groupPinnedFrameUrl(), "initial url") + await assertThrowsAsyncError( + try await alixGroup.updateGroupPinnedFrameUrl( + groupPinnedFrameUrl: "https://foo/bar.com") + ) + + try await alixGroup.sync() + try await boGroup.sync() + XCTAssertEqual( + try boGroup.permissionPolicySet().updateGroupPinnedFrameUrlPolicy, + .admin) + + // Update group pinned frame url permissions so alix can update + try await boGroup.updateGroupPinnedFrameUrlPermission( + newPermissionOption: .allow) + try await boGroup.sync() + try await alixGroup.sync() + XCTAssertEqual( + try boGroup.permissionPolicySet().updateGroupPinnedFrameUrlPolicy, + .allow) + + // Verify that alix can now update group pinned frame url + try await alixGroup.updateGroupPinnedFrameUrl( + groupPinnedFrameUrl: "https://foo/barz.com") + try await alixGroup.sync() + try await boGroup.sync() + XCTAssertEqual( + try boGroup.groupPinnedFrameUrl(), "https://foo/barz.com") + XCTAssertEqual( + try alixGroup.groupPinnedFrameUrl(), "https://foo/barz.com") + } + + func testCanCreateGroupWithCustomPermissions() async throws { + let fixtures = try await fixtures() + let permissionPolicySet = PermissionPolicySet( + addMemberPolicy: PermissionOption.admin, + removeMemberPolicy: PermissionOption.deny, + addAdminPolicy: PermissionOption.admin, + removeAdminPolicy: PermissionOption.superAdmin, + updateGroupNamePolicy: PermissionOption.admin, + updateGroupDescriptionPolicy: PermissionOption.allow, + updateGroupImagePolicy: PermissionOption.admin, + updateGroupPinnedFrameUrlPolicy: PermissionOption.deny + ) + _ = try await fixtures.boClient.conversations + .newGroupCustomPermissions( + with: [fixtures.alix.address, fixtures.caro.address], + permissionPolicySet: permissionPolicySet, + pinnedFrameUrl: "initial url" + ) + + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + + let alixPermissionSet = try alixGroup.permissionPolicySet() + XCTAssert(alixPermissionSet.addMemberPolicy == PermissionOption.admin) + XCTAssert( + alixPermissionSet.removeMemberPolicy == PermissionOption.deny) + XCTAssert(alixPermissionSet.addAdminPolicy == PermissionOption.admin) + XCTAssert( + alixPermissionSet.removeAdminPolicy == PermissionOption.superAdmin) + XCTAssert( + alixPermissionSet.updateGroupNamePolicy == PermissionOption.admin) + XCTAssert( + alixPermissionSet.updateGroupDescriptionPolicy + == PermissionOption.allow) + XCTAssert( + alixPermissionSet.updateGroupImagePolicy == PermissionOption.admin) + XCTAssert( + alixPermissionSet.updateGroupPinnedFrameUrlPolicy + == PermissionOption.deny) + } + + func testCreateGroupWithInvalidPermissionsFails() async throws { + let fixtures = try await fixtures() + // Add / remove admin can not be set to "allow" + let permissionPolicySetInvalid = PermissionPolicySet( + addMemberPolicy: PermissionOption.admin, + removeMemberPolicy: PermissionOption.deny, + addAdminPolicy: PermissionOption.allow, + removeAdminPolicy: PermissionOption.superAdmin, + updateGroupNamePolicy: PermissionOption.admin, + updateGroupDescriptionPolicy: PermissionOption.allow, + updateGroupImagePolicy: PermissionOption.admin, + updateGroupPinnedFrameUrlPolicy: PermissionOption.deny + ) + await assertThrowsAsyncError( + try await fixtures.boClient.conversations + .newGroupCustomPermissions( + with: [fixtures.alix.address, fixtures.caro.address], + permissionPolicySet: permissionPolicySetInvalid, + pinnedFrameUrl: "initial url" + ) + ) + } } diff --git a/Tests/XMTPTests/GroupTests.swift b/Tests/XMTPTests/GroupTests.swift index 0a392e4e..c4050a9a 100644 --- a/Tests/XMTPTests/GroupTests.swift +++ b/Tests/XMTPTests/GroupTests.swift @@ -1,488 +1,558 @@ -// -// GroupTests.swift -// -// -// Created by Pat Nakajima on 2/1/24. -// - import CryptoKit -import XCTest -@testable import XMTPiOS import LibXMTP +import XCTest import XMTPTestHelpers +@testable import XMTPiOS + func assertThrowsAsyncError( - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line, - _ errorHandler: (_ error: Error) -> Void = { _ in } + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (_ error: Error) -> Void = { _ in } ) async { - do { - _ = try await expression() - // expected error to be thrown, but it was not - let customMessage = message() - if customMessage.isEmpty { - XCTFail("Asynchronous call did not throw an error.", file: file, line: line) - } else { - XCTFail(customMessage, file: file, line: line) - } - } catch { - errorHandler(error) + do { + _ = try await expression() + // expected error to be thrown, but it was not + let customMessage = message() + if customMessage.isEmpty { + XCTFail( + "Asynchronous call did not throw an error.", file: file, + line: line) + } else { + XCTFail(customMessage, file: file, line: line) } + } catch { + errorHandler(error) + } } @available(iOS 16, *) class GroupTests: XCTestCase { - // Use these fixtures to talk to the local node - struct LocalFixtures { - var alice: PrivateKey! - var bob: PrivateKey! - var fred: PrivateKey! - var davonV3: PrivateKey! - var aliceClient: Client! - var bobClient: Client! - var fredClient: Client! - var davonV3Client: Client! - } - - func localFixtures() async throws -> LocalFixtures { - let key = try Crypto.secureRandomBytes(count: 32) - let options = ClientOptions.init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true, - encryptionKey: key - ) - let alice = try PrivateKey.generate() - let aliceClient = try await Client.create( - account: alice, - options: options - ) - let bob = try PrivateKey.generate() - let bobClient = try await Client.create( - account: bob, - options: options - ) - let fred = try PrivateKey.generate() - let fredClient = try await Client.create( - account: fred, - options: options - ) - - let davonV3 = try PrivateKey.generate() - let davonV3Client = try await Client.createV3( - account: davonV3, - options: options - ) - - return .init( - alice: alice, - bob: bob, - fred: fred, - davonV3: davonV3, - aliceClient: aliceClient, - bobClient: bobClient, - fredClient: fredClient, - davonV3Client: davonV3Client - ) - } - - func testCanDualSendConversations() async throws { - let fixtures = try await localFixtures() - let v2Convo = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bob.walletAddress) + func testCanCreateAGroupWithDefaultPermissions() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address]) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + XCTAssert(!boGroup.id.isEmpty) + XCTAssert(!alixGroup.id.isEmpty) + + try await alixGroup.addMembers(addresses: [fixtures.caro.address]) + try await boGroup.sync() - try await fixtures.aliceClient.conversations.sync() - try await fixtures.bobClient.conversations.sync() + var alixMembersCount = try await alixGroup.members.count + var boMembersCount = try await boGroup.members.count + XCTAssertEqual(alixMembersCount, 3) + XCTAssertEqual(boMembersCount, 3) - let alixDm = try await fixtures.aliceClient.findDm(address: fixtures.bob.walletAddress) - let boDm = try await fixtures.bobClient.findDm(address: fixtures.alice.walletAddress) + try await boGroup.addAdmin(inboxId: fixtures.alixClient.inboxID) - XCTAssertEqual(alixDm?.id, boDm?.id) + try await alixGroup.removeMembers(addresses: [fixtures.caro.address]) + try await boGroup.sync() - let alixConversationsListCount = try await fixtures.aliceClient.conversations.list().count - XCTAssertEqual(alixConversationsListCount, 1) + alixMembersCount = try await alixGroup.members.count + boMembersCount = try await boGroup.members.count + XCTAssertEqual(alixMembersCount, 2) + XCTAssertEqual(boMembersCount, 2) - let alixDmsListCount = try await fixtures.aliceClient.conversations.dms().count - XCTAssertEqual(alixDmsListCount, 1) + try await boGroup.addMembers(addresses: [fixtures.caro.address]) + try await alixGroup.sync() - let boDmsListCount = try await fixtures.bobClient.conversations.dms().count - XCTAssertEqual(boDmsListCount, 1) + try await boGroup.removeAdmin(inboxId: fixtures.alixClient.inboxID) + try await alixGroup.sync() - let boConversationsListCount = try await fixtures.bobClient.conversations.list().count - XCTAssertEqual(boConversationsListCount, 1) + alixMembersCount = try await alixGroup.members.count + boMembersCount = try await boGroup.members.count + XCTAssertEqual(alixMembersCount, 3) + XCTAssertEqual(boMembersCount, 3) + + XCTAssertEqual( + try boGroup.permissionPolicySet().addMemberPolicy, .allow) + XCTAssertEqual( + try alixGroup.permissionPolicySet().addMemberPolicy, .allow) + + XCTAssert( + try boGroup.isSuperAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssert( + try !boGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) + XCTAssert( + try alixGroup.isSuperAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssert( + try !alixGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) - let boFirstTopic = try await fixtures.bobClient.conversations.list().first?.topic - XCTAssertEqual(v2Convo.topic, boFirstTopic) } - func testCanDualSendMessages() async throws { - let fixtures = try await localFixtures() - let alixV2Convo = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bob.walletAddress) - let boV2Convo = try await fixtures.bobClient.conversations.list().first! - - try await fixtures.bobClient.conversations.sync() + func testCanCreateAGroupWithAdminPermissions() async throws { + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address], + permissions: GroupPermissionPreconfiguration.adminOnly) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try await fixtures.alixClient.conversations + .listGroups().first! + XCTAssert(!boGroup.id.isEmpty) + XCTAssert(!alixGroup.id.isEmpty) + + let boConsentResult = try boGroup.consentState() + XCTAssertEqual(boConsentResult, ConsentState.allowed) + + let alixConsentResult = try await fixtures.alixClient.preferences + .consentList.conversationState(conversationId: alixGroup.id) + XCTAssertEqual(alixConsentResult, ConsentState.unknown) + + try await boGroup.addMembers(addresses: [fixtures.caro.address]) + try await alixGroup.sync() - let alixDm = try await fixtures.aliceClient.findDm(address: fixtures.bob.walletAddress) - let boDm = try await fixtures.bobClient.findDm(address: fixtures.alice.walletAddress) + var alixMembersCount = try await alixGroup.members.count + var boMembersCount = try await boGroup.members.count + XCTAssertEqual(alixMembersCount, 3) + XCTAssertEqual(boMembersCount, 3) - try await alixV2Convo.send(content: "first") - try await boV2Convo.send(content: "second") + await assertThrowsAsyncError( + try await alixGroup.removeMembers(addresses: [ + fixtures.caro.address + ]) + ) + try await boGroup.sync() - try await alixDm?.sync() - try await boDm?.sync() + alixMembersCount = try await alixGroup.members.count + boMembersCount = try await boGroup.members.count + XCTAssertEqual(alixMembersCount, 3) + XCTAssertEqual(boMembersCount, 3) - let alixV2ConvoMessageCount = try await alixV2Convo.messages().count - XCTAssertEqual(alixV2ConvoMessageCount, 2) + try await boGroup.removeMembers(addresses: [fixtures.caro.address]) + try await alixGroup.sync() - let boV2ConvoMessageCount = try await boV2Convo.messages().count - XCTAssertEqual(alixV2ConvoMessageCount, boV2ConvoMessageCount) + alixMembersCount = try await alixGroup.members.count + boMembersCount = try await boGroup.members.count + XCTAssertEqual(alixMembersCount, 2) + XCTAssertEqual(boMembersCount, 2) - let boDmMessageCount = try await boDm?.messages().count - XCTAssertEqual(boDmMessageCount, 2) + await assertThrowsAsyncError( + try await alixGroup.addMembers(addresses: [fixtures.caro.address]) + ) + try await boGroup.sync() - let alixDmMessageCount = try await alixDm?.messages().count - XCTAssertEqual(alixDmMessageCount, 3) // Including the group membership update in the DM + alixMembersCount = try await alixGroup.members.count + boMembersCount = try await boGroup.members.count + XCTAssertEqual(alixMembersCount, 2) + XCTAssertEqual(boMembersCount, 2) + + XCTAssertEqual( + try boGroup.permissionPolicySet().addMemberPolicy, .admin) + XCTAssertEqual( + try alixGroup.permissionPolicySet().addMemberPolicy, .admin) + XCTAssert( + try boGroup.isSuperAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssert( + try !boGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) + XCTAssert( + try alixGroup.isSuperAdmin(inboxId: fixtures.boClient.inboxID)) + XCTAssert( + try !alixGroup.isSuperAdmin(inboxId: fixtures.alixClient.inboxID)) } - func testCanCreateAGroupWithDefaultPermissions() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - XCTAssert(!bobGroup.id.isEmpty) - XCTAssert(!aliceGroup.id.isEmpty) - - try await aliceGroup.addMembers(addresses: [fixtures.fred.address]) - try await bobGroup.sync() - - var aliceMembersCount = try await aliceGroup.members.count - var bobMembersCount = try await bobGroup.members.count - XCTAssertEqual(aliceMembersCount, 3) - XCTAssertEqual(bobMembersCount, 3) - - try await bobGroup.addAdmin(inboxId: fixtures.aliceClient.inboxID) - - try await aliceGroup.removeMembers(addresses: [fixtures.fred.address]) - try await bobGroup.sync() - - aliceMembersCount = try await aliceGroup.members.count - bobMembersCount = try await bobGroup.members.count - XCTAssertEqual(aliceMembersCount, 2) - XCTAssertEqual(bobMembersCount, 2) - - try await bobGroup.addMembers(addresses: [fixtures.fred.address]) - try await aliceGroup.sync() - - try await bobGroup.removeAdmin(inboxId: fixtures.aliceClient.inboxID) - try await aliceGroup.sync() - - aliceMembersCount = try await aliceGroup.members.count - bobMembersCount = try await bobGroup.members.count - XCTAssertEqual(aliceMembersCount, 3) - XCTAssertEqual(bobMembersCount, 3) - - XCTAssertEqual(try bobGroup.permissionPolicySet().addMemberPolicy, .allow) - XCTAssertEqual(try aliceGroup.permissionPolicySet().addMemberPolicy, .allow) - - XCTAssert(try bobGroup.isSuperAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssert(try !bobGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) - XCTAssert(try aliceGroup.isSuperAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssert(try !aliceGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) - + func testCanListGroups() async throws { + let fixtures = try await fixtures() + _ = try await fixtures.alixClient.conversations.newGroup(with: [ + fixtures.bo.address + ]) + _ = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.bo.address) + _ = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.alix.address) + + try await fixtures.alixClient.conversations.sync() + let alixGroupCount = try await fixtures.alixClient.conversations + .listGroups().count + + try await fixtures.boClient.conversations.sync() + let boGroupCount = try await fixtures.boClient.conversations + .listGroups().count + + XCTAssertEqual(1, alixGroupCount) + XCTAssertEqual(1, boGroupCount) + } + + func testCanFindConversationByTopic() async throws { + let fixtures = try await fixtures() + + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.caro.walletAddress + ]) + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.caro.walletAddress) + + let sameDm = try fixtures.boClient.findConversationByTopic( + topic: dm.topic) + let sameGroup = try fixtures.boClient.findConversationByTopic( + topic: group.topic) + + XCTAssertEqual(group.id, try sameGroup?.id) + XCTAssertEqual(dm.id, try sameDm?.id) } - func testCanCreateAGroupWithAdminPermissions() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address], permissions: GroupPermissionPreconfiguration.adminOnly) - try await fixtures.aliceClient.conversations.sync() - let aliceGroup = try await fixtures.aliceClient.conversations.groups().first! - XCTAssert(!bobGroup.id.isEmpty) - XCTAssert(!aliceGroup.id.isEmpty) - - let bobConsentResult = try await fixtures.bobClient.contacts.consentList.groupState(groupId: bobGroup.id) - XCTAssertEqual(bobConsentResult, ConsentState.allowed) + func testCanListConversations() async throws { + let fixtures = try await fixtures() + + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.caro.walletAddress) + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.caro.walletAddress + ]) + + let convoCount = try await fixtures.boClient.conversations + .list().count + let dmCount = try await fixtures.boClient.conversations.listDms().count + let groupCount = try await fixtures.boClient.conversations.listGroups() + .count + XCTAssertEqual(convoCount, 2) + XCTAssertEqual(dmCount, 1) + XCTAssertEqual(groupCount, 1) + + try await fixtures.caroClient.conversations.sync() + let convoCount2 = try await fixtures.caroClient.conversations.list() + .count + let groupCount2 = try await fixtures.caroClient.conversations + .listGroups().count + XCTAssertEqual(convoCount2, 2) + XCTAssertEqual(groupCount2, 1) + } - let aliceConsentResult = try await fixtures.aliceClient.contacts.consentList.groupState(groupId: aliceGroup.id) - XCTAssertEqual(aliceConsentResult, ConsentState.unknown) + func testCanListConversationsFiltered() async throws { + let fixtures = try await fixtures() - try await bobGroup.addMembers(addresses: [fixtures.fred.address]) - try await aliceGroup.sync() + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.caro.walletAddress) + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.caro.walletAddress + ]) - var aliceMembersCount = try await aliceGroup.members.count - var bobMembersCount = try await bobGroup.members.count - XCTAssertEqual(aliceMembersCount, 3) - XCTAssertEqual(bobMembersCount, 3) + let convoCount = try await fixtures.boClient.conversations + .list().count + let convoCountConsent = try await fixtures.boClient.conversations + .list(consentState: .allowed).count - await assertThrowsAsyncError( - try await aliceGroup.removeMembers(addresses: [fixtures.fred.address]) - ) - try await bobGroup.sync() + XCTAssertEqual(convoCount, 2) + XCTAssertEqual(convoCountConsent, 2) - aliceMembersCount = try await aliceGroup.members.count - bobMembersCount = try await bobGroup.members.count - XCTAssertEqual(aliceMembersCount, 3) - XCTAssertEqual(bobMembersCount, 3) - - try await bobGroup.removeMembers(addresses: [fixtures.fred.address]) - try await aliceGroup.sync() + try await group.updateConsentState(state: .denied) - aliceMembersCount = try await aliceGroup.members.count - bobMembersCount = try await bobGroup.members.count - XCTAssertEqual(aliceMembersCount, 2) - XCTAssertEqual(bobMembersCount, 2) + let convoCountAllowed = try await fixtures.boClient.conversations + .list(consentState: .allowed).count + let convoCountDenied = try await fixtures.boClient.conversations + .list(consentState: .denied).count - await assertThrowsAsyncError( - try await aliceGroup.addMembers(addresses: [fixtures.fred.address]) - ) - try await bobGroup.sync() - - aliceMembersCount = try await aliceGroup.members.count - bobMembersCount = try await bobGroup.members.count - XCTAssertEqual(aliceMembersCount, 2) - XCTAssertEqual(bobMembersCount, 2) - - XCTAssertEqual(try bobGroup.permissionPolicySet().addMemberPolicy, .admin) - XCTAssertEqual(try aliceGroup.permissionPolicySet().addMemberPolicy, .admin) - XCTAssert(try bobGroup.isSuperAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssert(try !bobGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) - XCTAssert(try aliceGroup.isSuperAdmin(inboxId: fixtures.bobClient.inboxID)) - XCTAssert(try !aliceGroup.isSuperAdmin(inboxId: fixtures.aliceClient.inboxID)) + XCTAssertEqual(convoCountAllowed, 1) + XCTAssertEqual(convoCountDenied, 1) } - func testCanListGroups() async throws { - let fixtures = try await localFixtures() - _ = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) - _ = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.bob.address) - _ = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.address) - - try await fixtures.aliceClient.conversations.sync() - let aliceGroupCount = try await fixtures.aliceClient.conversations.groups().count - - try await fixtures.bobClient.conversations.sync() - let bobGroupCount = try await fixtures.bobClient.conversations.groups().count - - XCTAssertEqual(1, aliceGroupCount) - XCTAssertEqual(1, bobGroupCount) + func testCanListConversationsOrder() async throws { + let fixtures = try await fixtures() + + let dm = try await fixtures.boClient.conversations.findOrCreateDm( + with: fixtures.caro.walletAddress) + let group1 = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.caro.walletAddress]) + let group2 = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.caro.walletAddress]) + + _ = try await dm.send(content: "Howdy") + _ = try await group2.send(content: "Howdy") + _ = try await fixtures.boClient.conversations.syncAllConversations() + + let conversations = try await fixtures.boClient.conversations + .list() + let conversationsOrdered = try await fixtures.boClient.conversations + .list(order: .lastMessage) + + XCTAssertEqual(conversations.count, 3) + XCTAssertEqual(conversationsOrdered.count, 3) + + XCTAssertEqual( + try conversations.map { try $0.id }, [dm.id, group1.id, group2.id]) + XCTAssertEqual( + try conversationsOrdered.map { try $0.id }, + [group2.id, dm.id, group1.id]) } - - func testCanListGroupsAndConversations() async throws { - let fixtures = try await localFixtures() - _ = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) - _ = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bob.address) - _ = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.bob.walletAddress) - _ = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.walletAddress) - - let aliceGroupCount = try await fixtures.aliceClient.conversations.list(includeGroups: true).count - - try await fixtures.bobClient.conversations.sync() - let bobGroupCount = try await fixtures.bobClient.conversations.list(includeGroups: true).count - XCTAssertEqual(2, aliceGroupCount) - XCTAssertEqual(2, bobGroupCount) + func testCanListGroupsAndConversations() async throws { + let fixtures = try await fixtures() + _ = try await fixtures.alixClient.conversations.newGroup(with: [ + fixtures.bo.address + ]) + _ = try await fixtures.alixClient.conversations.newConversation( + with: fixtures.bo.address) + _ = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.bo.walletAddress) + _ = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.alix.walletAddress) + + let alixGroupCount = try await fixtures.alixClient.conversations + .list().count + + try await fixtures.boClient.conversations.sync() + let boGroupCount = try await fixtures.boClient.conversations.list() + .count + + XCTAssertEqual(2, alixGroupCount) + XCTAssertEqual(3, boGroupCount) } func testCanListGroupMembers() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) + let fixtures = try await fixtures() + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) try await group.sync() let members = try await group.members.map(\.inboxId).sorted() let peerMembers = try await group.peerInboxIds.sorted() - XCTAssertEqual([fixtures.bobClient.inboxID, fixtures.aliceClient.inboxID].sorted(), members) - XCTAssertEqual([fixtures.bobClient.inboxID].sorted(), peerMembers) + XCTAssertEqual( + [fixtures.boClient.inboxID, fixtures.alixClient.inboxID].sorted(), + members) + XCTAssertEqual([fixtures.boClient.inboxID].sorted(), peerMembers) } func testCanAddGroupMembers() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) + let fixtures = try await fixtures() + fixtures.alixClient.register(codec: GroupUpdatedCodec()) + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) - try await group.addMembers(addresses: [fixtures.fred.address]) + try await group.addMembers(addresses: [fixtures.caro.address]) try await group.sync() let members = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - fixtures.fredClient.inboxID - ].sorted(), members) - - let groupChangedMessage: GroupUpdated = try await group.messages().first!.content() - XCTAssertEqual(groupChangedMessage.addedInboxes.map(\.inboxID), [fixtures.fredClient.inboxID]) + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + fixtures.caroClient.inboxID, + ].sorted(), members) + + let groupChangedMessage: GroupUpdated = try await group.messages() + .first!.content() + XCTAssertEqual( + groupChangedMessage.addedInboxes.map(\.inboxID), + [fixtures.caroClient.inboxID]) } - + func testCanAddGroupMembersByInboxId() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) + let fixtures = try await fixtures() + fixtures.alixClient.register(codec: GroupUpdatedCodec()) + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) - try await group.addMembersByInboxId(inboxIds: [fixtures.fredClient.inboxID]) + try await group.addMembersByInboxId(inboxIds: [ + fixtures.caroClient.inboxID + ]) try await group.sync() let members = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - fixtures.fredClient.inboxID - ].sorted(), members) - - let groupChangedMessage: GroupUpdated = try await group.messages().first!.content() - XCTAssertEqual(groupChangedMessage.addedInboxes.map(\.inboxID), [fixtures.fredClient.inboxID]) + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + fixtures.caroClient.inboxID, + ].sorted(), members) + + let groupChangedMessage: GroupUpdated = try await group.messages() + .first!.content() + XCTAssertEqual( + groupChangedMessage.addedInboxes.map(\.inboxID), + [fixtures.caroClient.inboxID]) } func testCanRemoveMembers() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address, fixtures.fred.address]) + let fixtures = try await fixtures() + fixtures.alixClient.register(codec: GroupUpdatedCodec()) + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address, fixtures.caro.address]) try await group.sync() let members = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - fixtures.fredClient.inboxID - ].sorted(), members) + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + fixtures.caroClient.inboxID, + ].sorted(), members) - try await group.removeMembers(addresses: [fixtures.fred.address]) + try await group.removeMembers(addresses: [fixtures.caro.address]) try await group.sync() let newMembers = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - ].sorted(), newMembers) - - let groupChangedMessage: GroupUpdated = try await group.messages().first!.content() - XCTAssertEqual(groupChangedMessage.removedInboxes.map(\.inboxID), [fixtures.fredClient.inboxID]) + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + ].sorted(), newMembers) + + let groupChangedMessage: GroupUpdated = try await group.messages() + .first!.content() + XCTAssertEqual( + groupChangedMessage.removedInboxes.map(\.inboxID), + [fixtures.caroClient.inboxID]) } - + func testCanRemoveMembersByInboxId() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address, fixtures.fred.address]) + let fixtures = try await fixtures() + fixtures.alixClient.register(codec: GroupUpdatedCodec()) + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address, fixtures.caro.address]) try await group.sync() let members = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - fixtures.fredClient.inboxID - ].sorted(), members) + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + fixtures.caroClient.inboxID, + ].sorted(), members) - try await group.removeMembersByInboxId(inboxIds: [fixtures.fredClient.inboxID]) + try await group.removeMembersByInboxId(inboxIds: [ + fixtures.caroClient.inboxID + ]) try await group.sync() let newMembers = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - ].sorted(), newMembers) - - let groupChangedMessage: GroupUpdated = try await group.messages().first!.content() - XCTAssertEqual(groupChangedMessage.removedInboxes.map(\.inboxID), [fixtures.fredClient.inboxID]) + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + ].sorted(), newMembers) + + let groupChangedMessage: GroupUpdated = try await group.messages() + .first!.content() + XCTAssertEqual( + groupChangedMessage.removedInboxes.map(\.inboxID), + [fixtures.caroClient.inboxID]) } - + func testCanMessage() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() let notOnNetwork = try PrivateKey.generate() - let canMessage = try await fixtures.aliceClient.canMessageV3(address: fixtures.bobClient.address) - let cannotMessage = try await fixtures.aliceClient.canMessageV3(addresses: [notOnNetwork.address, fixtures.bobClient.address]) + let canMessage = try await fixtures.alixClient.canMessage( + address: fixtures.boClient.address) + let cannotMessage = try await fixtures.alixClient.canMessage( + addresses: [notOnNetwork.address, fixtures.boClient.address]) XCTAssert(canMessage) XCTAssert(!(cannotMessage[notOnNetwork.address.lowercased()] ?? true)) } - + func testIsActive() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address, fixtures.fred.address]) + let fixtures = try await fixtures() + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address, fixtures.caro.address]) try await group.sync() let members = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - fixtures.fredClient.inboxID - ].sorted(), members) - - try await fixtures.fredClient.conversations.sync() - let fredGroup = try await fixtures.fredClient.conversations.groups().first - try await fredGroup?.sync() + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + fixtures.caroClient.inboxID, + ].sorted(), members) - var isAliceActive = try group.isActive() - var isFredActive = try fredGroup!.isActive() - - XCTAssert(isAliceActive) - XCTAssert(isFredActive) + try await fixtures.caroClient.conversations.sync() + let caroGroup = try await fixtures.caroClient.conversations.listGroups() + .first + try await caroGroup?.sync() - try await group.removeMembers(addresses: [fixtures.fred.address]) + var isalixActive = try group.isActive() + var iscaroActive = try caroGroup!.isActive() + + XCTAssert(isalixActive) + XCTAssert(iscaroActive) + + try await group.removeMembers(addresses: [fixtures.caro.address]) try await group.sync() let newMembers = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual([ - fixtures.bobClient.inboxID, - fixtures.aliceClient.inboxID, - ].sorted(), newMembers) - - try await fredGroup?.sync() - - isAliceActive = try group.isActive() - isFredActive = try fredGroup!.isActive() - - XCTAssert(isAliceActive) - XCTAssert(!isFredActive) + XCTAssertEqual( + [ + fixtures.boClient.inboxID, + fixtures.alixClient.inboxID, + ].sorted(), newMembers) + + try await caroGroup?.sync() + + isalixActive = try group.isActive() + iscaroActive = try caroGroup!.isActive() + + XCTAssert(isalixActive) + XCTAssert(!iscaroActive) } func testAddedByAddress() async throws { // Create clients - let fixtures = try await localFixtures() - - // Alice creates a group and adds Bob to the group - _ = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) - - // Bob syncs groups - this will decrypt the Welcome and then - // identify who added Bob to the group - try await fixtures.bobClient.conversations.sync() - - // Check Bob's group for the added_by_address of the inviter - let bobGroup = try await fixtures.bobClient.conversations.groups().first - let aliceAddress = fixtures.aliceClient.inboxID - let whoAddedBob = try bobGroup?.addedByInboxId() - + let fixtures = try await fixtures() + + // alix creates a group and adds bo to the group + _ = try await fixtures.alixClient.conversations.newGroup(with: [ + fixtures.bo.address + ]) + + // bo syncs groups - this will decrypt the Welcome and then + // identify who added bo to the group + try await fixtures.boClient.conversations.sync() + + // Check bo's group for the added_by_address of the inviter + let boGroup = try await fixtures.boClient.conversations.listGroups() + .first + let alixAddress = fixtures.alixClient.inboxID + let whoAddedbo = try boGroup?.addedByInboxId() + // Verify the welcome host_credential is equal to Amal's - XCTAssertEqual(aliceAddress, whoAddedBob) + XCTAssertEqual(alixAddress, whoAddedbo) } func testCannotStartGroupWithSelf() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() await assertThrowsAsyncError( - try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.alice.address]) + try await fixtures.alixClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) ) } func testCanStartEmptyGroup() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: []) + let fixtures = try await fixtures() + let group = try await fixtures.alixClient.conversations.newGroup( + with: []) XCTAssert(!group.id.isEmpty) } func testCannotStartGroupWithNonRegisteredIdentity() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() let nonRegistered = try PrivateKey.generate() do { - _ = try await fixtures.aliceClient.conversations.newGroup(with: [nonRegistered.address]) + _ = try await fixtures.alixClient.conversations.newGroup(with: [ + nonRegistered.address + ]) XCTFail("did not throw error") } catch { - if case let GroupError.memberNotRegistered(addresses) = error { - XCTAssertEqual([nonRegistered.address.lowercased()], addresses.map { $0.lowercased() }) + if case let ConversationError.memberNotRegistered(addresses) = error + { + XCTAssertEqual( + [nonRegistered.address.lowercased()], + addresses.map { $0.lowercased() }) } else { XCTFail("did not throw correct error") } @@ -490,108 +560,103 @@ class GroupTests: XCTestCase { } func testGroupStartsWithAllowedState() async throws { - let fixtures = try await localFixtures() - let bobGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.walletAddress]) + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.walletAddress]) - _ = try await bobGroup.send(content: "howdy") - _ = try await bobGroup.send(content: "gm") - try await bobGroup.sync() - - let isGroupAllowedResult = try await fixtures.bobClient.contacts.isGroupAllowed(groupId: bobGroup.id) - XCTAssertTrue(isGroupAllowedResult) + _ = try await boGroup.send(content: "howdy") + _ = try await boGroup.send(content: "gm") + try await boGroup.sync() - let groupStateResult = try await fixtures.bobClient.contacts.consentList.groupState(groupId: bobGroup.id) + let groupStateResult = try boGroup.consentState() XCTAssertEqual(groupStateResult, ConsentState.allowed) } - + func testCanSendMessagesToGroup() async throws { - let fixtures = try await localFixtures() - let aliceGroup = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) + let fixtures = try await fixtures() + fixtures.boClient.register(codec: GroupUpdatedCodec()) + fixtures.alixClient.register(codec: GroupUpdatedCodec()) + let alixGroup = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) let membershipChange = GroupUpdated() - try await fixtures.bobClient.conversations.sync() - let bobGroup = try await fixtures.bobClient.conversations.groups()[0] - - _ = try await aliceGroup.send(content: "sup gang original") - let messageId = try await aliceGroup.send(content: "sup gang") - _ = try await aliceGroup.send(content: membershipChange, options: SendOptions(contentType: ContentTypeGroupUpdated)) + try await fixtures.boClient.conversations.sync() + let boGroup = try await fixtures.boClient.conversations.listGroups()[ + 0] - try await aliceGroup.sync() - let aliceGroupsCount = try await aliceGroup.messages().count - XCTAssertEqual(3, aliceGroupsCount) - let aliceMessage = try await aliceGroup.messages().first! + _ = try await alixGroup.send(content: "sup gang original") + let messageId = try await alixGroup.send(content: "sup gang") + _ = try await alixGroup.send( + content: membershipChange, + options: SendOptions(contentType: ContentTypeGroupUpdated)) - try await bobGroup.sync() - let bobGroupsCount = try await bobGroup.messages().count - XCTAssertEqual(2, bobGroupsCount) - let bobMessage = try await bobGroup.messages().first! - - XCTAssertEqual("sup gang", try aliceMessage.content()) - XCTAssertEqual(messageId, aliceMessage.id) - XCTAssertEqual(.published, aliceMessage.deliveryStatus) - XCTAssertEqual("sup gang", try bobMessage.content()) - } - - func testCanListGroupMessages() async throws { - let fixtures = try await localFixtures() - let aliceGroup = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) - _ = try await aliceGroup.send(content: "howdy") - _ = try await aliceGroup.send(content: "gm") - - var aliceMessagesCount = try await aliceGroup.messages().count - var aliceMessagesPublishedCount = try await aliceGroup.messages(deliveryStatus: .published).count - XCTAssertEqual(3, aliceMessagesCount) - XCTAssertEqual(3, aliceMessagesPublishedCount) - - try await aliceGroup.sync() - - aliceMessagesCount = try await aliceGroup.messages().count - let aliceMessagesUnpublishedCount = try await aliceGroup.messages(deliveryStatus: .unpublished).count - aliceMessagesPublishedCount = try await aliceGroup.messages(deliveryStatus: .published).count - XCTAssertEqual(3, aliceMessagesCount) - XCTAssertEqual(0, aliceMessagesUnpublishedCount) - XCTAssertEqual(3, aliceMessagesPublishedCount) - - try await fixtures.bobClient.conversations.sync() - let bobGroup = try await fixtures.bobClient.conversations.groups()[0] - try await bobGroup.sync() - - let bobMessagesCount = try await bobGroup.messages().count - let bobMessagesUnpublishedCount = try await bobGroup.messages(deliveryStatus: .unpublished).count - let bobMessagesPublishedCount = try await bobGroup.messages(deliveryStatus: .published).count - XCTAssertEqual(2, bobMessagesCount) - XCTAssertEqual(0, bobMessagesUnpublishedCount) - XCTAssertEqual(2, bobMessagesPublishedCount) + try await alixGroup.sync() + let alixGroupsCount = try await alixGroup.messages().count + XCTAssertEqual(3, alixGroupsCount) + let alixMessage = try await alixGroup.messages().first! + try await boGroup.sync() + let boGroupsCount = try await boGroup.messages().count + XCTAssertEqual(2, boGroupsCount) + let boMessage = try await boGroup.messages().first! + + XCTAssertEqual("sup gang", try alixMessage.content()) + XCTAssertEqual(messageId, alixMessage.id) + XCTAssertEqual(.published, alixMessage.deliveryStatus) + XCTAssertEqual("sup gang", try boMessage.content()) } - - func testCanSendMessagesToGroupDecrypted() async throws { - let fixtures = try await localFixtures() - let aliceGroup = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) - try await fixtures.bobClient.conversations.sync() - let bobGroup = try await fixtures.bobClient.conversations.groups()[0] + func testCanListGroupMessages() async throws { + let fixtures = try await fixtures() + let alixGroup = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) + _ = try await alixGroup.send(content: "howdy") + _ = try await alixGroup.send(content: "gm") + + var alixMessagesCount = try await alixGroup.messages().count + var alixMessagesPublishedCount = try await alixGroup.messages( + deliveryStatus: .published + ).count + XCTAssertEqual(3, alixMessagesCount) + XCTAssertEqual(3, alixMessagesPublishedCount) - _ = try await aliceGroup.send(content: "sup gang original") - _ = try await aliceGroup.send(content: "sup gang") + try await alixGroup.sync() - try await aliceGroup.sync() - let aliceGroupsCount = try await aliceGroup.decryptedMessages().count - XCTAssertEqual(3, aliceGroupsCount) - let aliceMessage = try await aliceGroup.decryptedMessages().first! + alixMessagesCount = try await alixGroup.messages().count + let alixMessagesUnpublishedCount = try await alixGroup.messages( + deliveryStatus: .unpublished + ).count + alixMessagesPublishedCount = try await alixGroup.messages( + deliveryStatus: .published + ).count + XCTAssertEqual(3, alixMessagesCount) + XCTAssertEqual(0, alixMessagesUnpublishedCount) + XCTAssertEqual(3, alixMessagesPublishedCount) + + try await fixtures.boClient.conversations.sync() + let boGroup = try await fixtures.boClient.conversations.listGroups()[ + 0] + try await boGroup.sync() - try await bobGroup.sync() - let bobGroupsCount = try await bobGroup.decryptedMessages().count - XCTAssertEqual(2, bobGroupsCount) - let bobMessage = try await bobGroup.decryptedMessages().first! + let boMessagesCount = try await boGroup.messages().count + let boMessagesUnpublishedCount = try await boGroup.messages( + deliveryStatus: .unpublished + ).count + let boMessagesPublishedCount = try await boGroup.messages( + deliveryStatus: .published + ).count + XCTAssertEqual(2, boMessagesCount) + XCTAssertEqual(0, boMessagesUnpublishedCount) + XCTAssertEqual(2, boMessagesPublishedCount) - XCTAssertEqual("sup gang", String(data: Data(aliceMessage.encodedContent.content), encoding: .utf8)) - XCTAssertEqual("sup gang", String(data: Data(bobMessage.encodedContent.content), encoding: .utf8)) } - + func testCanStreamGroupMessages() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) + let fixtures = try await fixtures() + fixtures.boClient.register(codec: GroupUpdatedCodec()) + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) let membershipChange = GroupUpdated() let expectation1 = XCTestExpectation(description: "got a message") expectation1.expectedFulfillmentCount = 1 @@ -603,132 +668,171 @@ class GroupTests: XCTestCase { } _ = try await group.send(content: "hi") - _ = try await group.send(content: membershipChange, options: SendOptions(contentType: ContentTypeGroupUpdated)) + _ = try await group.send( + content: membershipChange, + options: SendOptions(contentType: ContentTypeGroupUpdated)) await fulfillment(of: [expectation1], timeout: 3) } - + func testCanStreamGroups() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() let expectation1 = XCTestExpectation(description: "got a group") Task(priority: .userInitiated) { - for try await _ in try await fixtures.aliceClient.conversations.streamGroups() { + for try await _ in await fixtures.alixClient.conversations + .stream() + { expectation1.fulfill() } } - _ = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - _ = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.address) + _ = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) + _ = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.alix.address) await fulfillment(of: [expectation1], timeout: 3) } - + func testCanStreamGroupsAndConversationsWorksGroups() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() let expectation1 = XCTestExpectation(description: "got a conversation") expectation1.expectedFulfillmentCount = 2 Task(priority: .userInitiated) { - for try await _ in await fixtures.aliceClient.conversations.streamAll() { + for try await _ in await fixtures.alixClient.conversations.stream() + { expectation1.fulfill() } } - _ = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - _ = try await fixtures.bobClient.conversations.newConversation(with: fixtures.alice.address) - _ = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.address) + _ = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) + _ = try await fixtures.boClient.conversations.newConversation( + with: fixtures.alix.address) + _ = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.alix.address) await fulfillment(of: [expectation1], timeout: 3) } - + func testStreamGroupsAndAllMessages() async throws { - let fixtures = try await localFixtures() - + let fixtures = try await fixtures() + let expectation1 = XCTestExpectation(description: "got a group") let expectation2 = XCTestExpectation(description: "got a message") - Task(priority: .userInitiated) { - for try await _ in try await fixtures.aliceClient.conversations.streamGroups() { + for try await _ in await fixtures.alixClient.conversations + .stream() + { expectation1.fulfill() } } - + Task(priority: .userInitiated) { - for try await _ in await fixtures.aliceClient.conversations.streamAllMessages(includeGroups: true) { + for try await _ in await fixtures.alixClient.conversations + .streamAllMessages() + { expectation2.fulfill() } } - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) _ = try await group.send(content: "hello") await fulfillment(of: [expectation1, expectation2], timeout: 3) } - + func testCanStreamAndUpdateNameWithoutForkingGroup() async throws { - let fixtures = try await localFixtures() - + let fixtures = try await fixtures() + let expectation = XCTestExpectation(description: "got a message") expectation.expectedFulfillmentCount = 5 Task(priority: .userInitiated) { - for try await _ in await fixtures.bobClient.conversations.streamAllGroupMessages(){ + for try await _ in await fixtures.boClient.conversations + .streamAllMessages() + { expectation.fulfill() } } - let alixGroup = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) + let alixGroup = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) try await alixGroup.updateGroupName(groupName: "hello") _ = try await alixGroup.send(content: "hello1") - - try await fixtures.bobClient.conversations.sync() - let boGroups = try await fixtures.bobClient.conversations.groups() + try await fixtures.boClient.conversations.sync() + + let boGroups = try await fixtures.boClient.conversations.listGroups() XCTAssertEqual(boGroups.count, 1, "bo should have 1 group") let boGroup = boGroups[0] try await boGroup.sync() - + let boMessages1 = try await boGroup.messages() - XCTAssertEqual(boMessages1.count, 2, "should have 2 messages on first load received \(boMessages1.count)") - + XCTAssertEqual( + boMessages1.count, 2, + "should have 2 messages on first load received \(boMessages1.count)" + ) + _ = try await boGroup.send(content: "hello2") _ = try await boGroup.send(content: "hello3") try await alixGroup.sync() let alixMessages = try await alixGroup.messages() for message in alixMessages { - print("message", message.encodedContent.type, message.encodedContent.type.typeID) + print( + "message", message.encodedContent.type, + message.encodedContent.type.typeID) } - XCTAssertEqual(alixMessages.count, 5, "should have 5 messages on first load received \(alixMessages.count)") + XCTAssertEqual( + alixMessages.count, 5, + "should have 5 messages on first load received \(alixMessages.count)" + ) _ = try await alixGroup.send(content: "hello4") try await boGroup.sync() let boMessages2 = try await boGroup.messages() for message in boMessages2 { - print("message", message.encodedContent.type, message.encodedContent.type.typeID) + print( + "message", message.encodedContent.type, + message.encodedContent.type.typeID) } - XCTAssertEqual(boMessages2.count, 5, "should have 5 messages on second load received \(boMessages2.count)") + XCTAssertEqual( + boMessages2.count, 5, + "should have 5 messages on second load received \(boMessages2.count)" + ) await fulfillment(of: [expectation], timeout: 3) } - + func testCanStreamAllMessages() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() let expectation1 = XCTestExpectation(description: "got a conversation") expectation1.expectedFulfillmentCount = 2 - let convo = try await fixtures.bobClient.conversations.newConversation(with: fixtures.alice.address) - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - let dm = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.address) - - try await fixtures.aliceClient.conversations.sync() + let convo = try await fixtures.boClient.conversations.newConversation( + with: fixtures.alix.address) + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) + let dm = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.alix.address) + + try await fixtures.alixClient.conversations.sync() Task(priority: .userInitiated) { - for try await _ in try await fixtures.aliceClient.conversations.streamAllMessages(includeGroups: true) { + for try await _ in await fixtures.alixClient.conversations + .streamAllMessages() + { expectation1.fulfill() } } @@ -739,41 +843,22 @@ class GroupTests: XCTestCase { await fulfillment(of: [expectation1], timeout: 3) } - - func testCanStreamAllDecryptedMessages() async throws { - let fixtures = try await localFixtures() - let membershipChange = GroupUpdated() - - let expectation1 = XCTestExpectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - let convo = try await fixtures.bobClient.conversations.newConversation(with: fixtures.alice.address) - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - let dm = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.address) - try await fixtures.aliceClient.conversations.sync() - Task(priority: .userInitiated) { - for try await _ in await fixtures.aliceClient.conversations.streamAllDecryptedMessages(includeGroups: true) { - expectation1.fulfill() - } - } - _ = try await group.send(content: "hi") - _ = try await group.send(content: membershipChange, options: SendOptions(contentType: ContentTypeGroupUpdated)) - _ = try await convo.send(content: "hi") - _ = try await dm.send(content: "hi") - - await fulfillment(of: [expectation1], timeout: 3) - } - func testCanStreamAllGroupMessages() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() let expectation1 = XCTestExpectation(description: "got a conversation") - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - let dm = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.address) - try await fixtures.aliceClient.conversations.sync() + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) + let dm = try await fixtures.caroClient.conversations.findOrCreateDm( + with: fixtures.alix.address) + try await fixtures.alixClient.conversations.sync() Task(priority: .userInitiated) { - for try await _ in await fixtures.aliceClient.conversations.streamAllGroupMessages() { + for try await _ in await fixtures.alixClient.conversations + .streamAllMessages() + { expectation1.fulfill() } } @@ -783,177 +868,175 @@ class GroupTests: XCTestCase { await fulfillment(of: [expectation1], timeout: 3) } - - func testCanStreamAllGroupDecryptedMessages() async throws { - let fixtures = try await localFixtures() - - let expectation1 = XCTestExpectation(description: "got a conversation") - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - let dm = try await fixtures.davonV3Client.conversations.findOrCreateDm(with: fixtures.alice.address) - try await fixtures.aliceClient.conversations.sync() - Task(priority: .userInitiated) { - for try await _ in await fixtures.aliceClient.conversations.streamAllGroupDecryptedMessages() { - expectation1.fulfill() - } - } + func testCanUpdateGroupMetadata() async throws { + let fixtures = try await fixtures() + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address], name: "Start Name", + imageUrlSquare: "starturl.com") - _ = try await group.send(content: "hi") - _ = try await dm.send(content: "hi") - - await fulfillment(of: [expectation1], timeout: 3) - } - - func testCanUpdateGroupMetadata() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address], name: "Start Name", imageUrlSquare: "starturl.com") - - var groupName = try group.groupName() + var groupName = try group.groupName() var groupImageUrlSquare = try group.groupImageUrlSquare() - - XCTAssertEqual(groupName, "Start Name") - XCTAssertEqual(groupImageUrlSquare, "starturl.com") + XCTAssertEqual(groupName, "Start Name") + XCTAssertEqual(groupImageUrlSquare, "starturl.com") - try await group.updateGroupName(groupName: "Test Group Name 1") + try await group.updateGroupName(groupName: "Test Group Name 1") try await group.updateGroupImageUrlSquare(imageUrlSquare: "newurl.com") - - groupName = try group.groupName() + + groupName = try group.groupName() groupImageUrlSquare = try group.groupImageUrlSquare() - XCTAssertEqual(groupName, "Test Group Name 1") + XCTAssertEqual(groupName, "Test Group Name 1") XCTAssertEqual(groupImageUrlSquare, "newurl.com") - - let bobConv = try await fixtures.bobClient.conversations.list(includeGroups: true)[0] - let bobGroup: Group; - switch bobConv { - case .v1(_): - XCTFail("failed converting conversation to group") - return - case .v2(_): - XCTFail("failed converting conversation to group") - return - case .group(let group): - bobGroup = group - case .dm(_): - XCTFail("failed converting conversation to group") - return - } - groupName = try bobGroup.groupName() - XCTAssertEqual(groupName, "Start Name") - - try await bobGroup.sync() - groupName = try bobGroup.groupName() - groupImageUrlSquare = try bobGroup.groupImageUrlSquare() - + + try await fixtures.boClient.conversations.sync() + let boGroup = try fixtures.boClient.findGroup(groupId: group.id)! + groupName = try boGroup.groupName() + XCTAssertEqual(groupName, "Start Name") + + try await boGroup.sync() + groupName = try boGroup.groupName() + groupImageUrlSquare = try boGroup.groupImageUrlSquare() + XCTAssertEqual(groupImageUrlSquare, "newurl.com") - XCTAssertEqual(groupName, "Test Group Name 1") - } - + XCTAssertEqual(groupName, "Test Group Name 1") + } + func testGroupConsent() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - let isAllowed = try await fixtures.bobClient.contacts.isGroupAllowed(groupId: group.id) - XCTAssert(isAllowed) + let fixtures = try await fixtures() + let group = try await fixtures.boClient.conversations.newGroup(with: [ + fixtures.alix.address + ]) XCTAssertEqual(try group.consentState(), .allowed) - - try await fixtures.bobClient.contacts.denyGroups(groupIds: [group.id]) - let isDenied = try await fixtures.bobClient.contacts.isGroupDenied(groupId: group.id) - XCTAssert(isDenied) + + try await group.updateConsentState(state: .denied) + let isDenied = try await fixtures.boClient.preferences.consentList + .conversationState(conversationId: group.id) + XCTAssertEqual(isDenied, .denied) XCTAssertEqual(try group.consentState(), .denied) - + try await group.updateConsentState(state: .allowed) - let isAllowed2 = try await fixtures.bobClient.contacts.isGroupAllowed(groupId: group.id) - XCTAssert(isAllowed2) XCTAssertEqual(try group.consentState(), .allowed) } - + func testCanAllowAndDenyInboxId() async throws { - let fixtures = try await localFixtures() - let boGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - var isInboxAllowed = try await fixtures.bobClient.contacts.isInboxAllowed(inboxId: fixtures.aliceClient.address) - var isInboxDenied = try await fixtures.bobClient.contacts.isInboxDenied(inboxId: fixtures.aliceClient.address) - XCTAssert(!isInboxAllowed) - XCTAssert(!isInboxDenied) - - - try await fixtures.bobClient.contacts.allowInboxes(inboxIds: [fixtures.aliceClient.inboxID]) - var alixMember = try await boGroup.members.first(where: { member in member.inboxId == fixtures.aliceClient.inboxID }) + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address]) + let inboxState = try await fixtures.boClient.preferences.consentList + .inboxIdState( + inboxId: fixtures.alixClient.inboxID) + XCTAssertEqual(inboxState, .unknown) + + try await fixtures.boClient.preferences.consentList.setConsentState( + entries: [ + ConsentListEntry( + value: fixtures.alixClient.inboxID, entryType: .inbox_id, + consentType: .allowed) + ]) + var alixMember = try await boGroup.members.first(where: { member in + member.inboxId == fixtures.alixClient.inboxID + }) XCTAssertEqual(alixMember?.consentState, .allowed) - isInboxAllowed = try await fixtures.bobClient.contacts.isInboxAllowed(inboxId: fixtures.aliceClient.inboxID) - XCTAssert(isInboxAllowed) - isInboxDenied = try await fixtures.bobClient.contacts.isInboxDenied(inboxId: fixtures.aliceClient.inboxID) - XCTAssert(!isInboxDenied) - - - try await fixtures.bobClient.contacts.denyInboxes(inboxIds: [fixtures.aliceClient.inboxID]) - alixMember = try await boGroup.members.first(where: { member in member.inboxId == fixtures.aliceClient.inboxID }) + let inboxState2 = try await fixtures.boClient.preferences.consentList + .inboxIdState( + inboxId: fixtures.alixClient.inboxID) + XCTAssertEqual(inboxState2, .allowed) + + try await fixtures.boClient.preferences.consentList.setConsentState( + entries: [ + ConsentListEntry( + value: fixtures.alixClient.inboxID, entryType: .inbox_id, + consentType: .denied) + ]) + alixMember = try await boGroup.members.first(where: { member in + member.inboxId == fixtures.alixClient.inboxID + }) XCTAssertEqual(alixMember?.consentState, .denied) - - isInboxAllowed = try await fixtures.bobClient.contacts.isInboxAllowed(inboxId: fixtures.aliceClient.inboxID) - isInboxDenied = try await fixtures.bobClient.contacts.isInboxDenied(inboxId: fixtures.aliceClient.inboxID) - XCTAssert(!isInboxAllowed) - XCTAssert(isInboxDenied) - - try await fixtures.bobClient.contacts.allow(addresses: [fixtures.aliceClient.address]) - let isAddressAllowed = try await fixtures.bobClient.contacts.isAllowed(fixtures.aliceClient.address) - let isAddressDenied = try await fixtures.bobClient.contacts.isDenied(fixtures.aliceClient.address) - XCTAssert(isAddressAllowed) - XCTAssert(!isAddressDenied) - isInboxAllowed = try await fixtures.bobClient.contacts.isInboxAllowed(inboxId: fixtures.aliceClient.inboxID) - isInboxDenied = try await fixtures.bobClient.contacts.isInboxDenied(inboxId: fixtures.aliceClient.inboxID) - XCTAssert(isInboxAllowed) - XCTAssert(!isInboxDenied) + + let inboxState3 = try await fixtures.boClient.preferences.consentList + .inboxIdState( + inboxId: fixtures.alixClient.inboxID) + XCTAssertEqual(inboxState3, .denied) + + try await fixtures.boClient.preferences.consentList.setConsentState( + entries: [ + ConsentListEntry( + value: fixtures.alixClient.address, entryType: .address, + consentType: .allowed) + ]) + let inboxState4 = try await fixtures.boClient.preferences.consentList + .inboxIdState( + inboxId: fixtures.alixClient.inboxID) + XCTAssertEqual(inboxState4, .allowed) + let addressState = try await fixtures.boClient.preferences.consentList + .addressState(address: fixtures.alixClient.address) + XCTAssertEqual(addressState, .allowed) } - + func testCanFetchGroupById() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let boGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - try await fixtures.aliceClient.conversations.sync() - let alixGroup = try fixtures.aliceClient.findGroup(groupId: boGroup.id) + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address]) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try fixtures.alixClient.findGroup(groupId: boGroup.id) XCTAssertEqual(alixGroup?.id, boGroup.id) } func testCanFetchMessageById() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() - let boGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address]) let boMessageId = try await boGroup.send(content: "Hello") - try await fixtures.aliceClient.conversations.sync() - let alixGroup = try fixtures.aliceClient.findGroup(groupId: boGroup.id) + try await fixtures.alixClient.conversations.sync() + let alixGroup = try fixtures.alixClient.findGroup(groupId: boGroup.id) try await alixGroup?.sync() - _ = try fixtures.aliceClient.findMessage(messageId: boMessageId) + _ = try fixtures.alixClient.findMessage(messageId: boMessageId) XCTAssertEqual(alixGroup?.id, boGroup.id) } - + func testUnpublishedMessages() async throws { - let fixtures = try await localFixtures() - let boGroup = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - - try await fixtures.aliceClient.conversations.sync() - let alixGroup = try fixtures.aliceClient.findGroup(groupId: boGroup.id)! - let isGroupAllowed = try await fixtures.aliceClient.contacts.isGroupAllowed(groupId: boGroup.id) - XCTAssert(!isGroupAllowed) - let preparedMessageId = try await alixGroup.prepareMessage(content: "Test text") - let isGroupAllowed2 = try await fixtures.aliceClient.contacts.isGroupAllowed(groupId: boGroup.id) - XCTAssert(isGroupAllowed2) + let fixtures = try await fixtures() + let boGroup = try await fixtures.boClient.conversations.newGroup( + with: [fixtures.alix.address]) + + try await fixtures.alixClient.conversations.sync() + let alixGroup = try fixtures.alixClient.findGroup(groupId: boGroup.id)! + let isGroupAllowed = try await fixtures.alixClient.preferences + .consentList.conversationState(conversationId: boGroup.id) + XCTAssertEqual(isGroupAllowed, .unknown) + let preparedMessageId = try await alixGroup.prepareMessage( + content: "Test text") + let isGroupAllowed2 = try await fixtures.alixClient.preferences + .consentList.conversationState(conversationId: boGroup.id) + XCTAssertEqual(isGroupAllowed2, .allowed) let messageCount = try await alixGroup.messages().count XCTAssertEqual(messageCount, 1) - let messageCountPublished = try await alixGroup.messages(deliveryStatus: .published).count - let messageCountUnpublished = try await alixGroup.messages(deliveryStatus: .unpublished).count + let messageCountPublished = try await alixGroup.messages( + deliveryStatus: .published + ).count + let messageCountUnpublished = try await alixGroup.messages( + deliveryStatus: .unpublished + ).count XCTAssertEqual(messageCountPublished, 0) XCTAssertEqual(messageCountUnpublished, 1) _ = try await alixGroup.publishMessages() try await alixGroup.sync() - let messageCountPublished2 = try await alixGroup.messages(deliveryStatus: .published).count - let messageCountUnpublished2 = try await alixGroup.messages(deliveryStatus: .unpublished).count + let messageCountPublished2 = try await alixGroup.messages( + deliveryStatus: .published + ).count + let messageCountUnpublished2 = try await alixGroup.messages( + deliveryStatus: .unpublished + ).count let messageCount2 = try await alixGroup.messages().count XCTAssertEqual(messageCountPublished2, 1) XCTAssertEqual(messageCountUnpublished2, 0) @@ -963,58 +1046,65 @@ class GroupTests: XCTestCase { XCTAssertEqual(preparedMessageId, messages.first!.id) } - + func testCanSyncManyGroupsInUnderASecond() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() var groups: [Group] = [] for _ in 0..<100 { - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) groups.append(group) } - try await fixtures.bobClient.conversations.sync() - let bobGroup = try fixtures.bobClient.findGroup(groupId: groups[0].id) + try await fixtures.boClient.conversations.sync() + let boGroup = try fixtures.boClient.findGroup(groupId: groups[0].id) _ = try await groups[0].send(content: "hi") - let messageCount = try await bobGroup!.messages().count + let messageCount = try await boGroup!.messages().count XCTAssertEqual(messageCount, 0) do { let start = Date() - let numGroupsSynced = try await fixtures.bobClient.conversations.syncAllGroups() + let numGroupsSynced = try await fixtures.boClient.conversations + .syncAllConversations() let end = Date() print(end.timeIntervalSince(start)) XCTAssert(end.timeIntervalSince(start) < 1) - XCTAssert(numGroupsSynced == 100) + XCTAssert(numGroupsSynced == 100) } catch { print("Failed to list groups members: \(error)") - throw error // Rethrow the error to fail the test if group creation fails + throw error // Rethrow the error to fail the test if group creation fails } - - let messageCount2 = try await bobGroup!.messages().count + + let messageCount2 = try await boGroup!.messages().count XCTAssertEqual(messageCount2, 1) - - for aliceConv in try await fixtures.aliceClient.conversations.list(includeGroups: true) { - guard case let .group(aliceGroup) = aliceConv else { - XCTFail("failed converting conversation to group") - return - } - try await aliceGroup.removeMembers(addresses: [fixtures.bobClient.address]) - } - - // first syncAllGroups after removal still sync groups in order to process the removal - var numGroupsSynced = try await fixtures.bobClient.conversations.syncAllGroups() - XCTAssert(numGroupsSynced == 100) - - // next syncAllGroups only will sync active groups - numGroupsSynced = try await fixtures.bobClient.conversations.syncAllGroups() - XCTAssert(numGroupsSynced == 0) + + for alixConv in try await fixtures.alixClient.conversations.list() { + guard case let .group(alixGroup) = alixConv else { + XCTFail("failed converting conversation to group") + return + } + try await alixGroup.removeMembers(addresses: [ + fixtures.boClient.address + ]) + } + + // first syncAllGroups after removal still sync groups in order to process the removal + var numGroupsSynced = try await fixtures.boClient.conversations + .syncAllConversations() + XCTAssert(numGroupsSynced == 100) + + // next syncAllGroups only will sync active groups + numGroupsSynced = try await fixtures.boClient.conversations + .syncAllConversations() + XCTAssert(numGroupsSynced == 0) } - + func testCanListManyMembersInParallelInUnderASecond() async throws { - let fixtures = try await localFixtures() + let fixtures = try await fixtures() var groups: [Group] = [] for _ in 0..<100 { - let group = try await fixtures.aliceClient.conversations.newGroup(with: [fixtures.bob.address]) + let group = try await fixtures.alixClient.conversations.newGroup( + with: [fixtures.bo.address]) groups.append(group) } do { @@ -1025,10 +1115,10 @@ class GroupTests: XCTestCase { XCTAssert(end.timeIntervalSince(start) < 1) } catch { print("Failed to list groups members: \(error)") - throw error // Rethrow the error to fail the test if group creation fails + throw error // Rethrow the error to fail the test if group creation fails } } - + func listMembersInParallel(groups: [Group]) async throws { await withThrowingTaskGroup(of: [Member].self) { taskGroup in for group in groups { @@ -1038,65 +1128,4 @@ class GroupTests: XCTestCase { } } } - - func testCanStreamAllDecryptedMessagesAndCancelStream() async throws { - let fixtures = try await localFixtures() - - var messages = 0 - let messagesQueue = DispatchQueue(label: "messages.queue") // Serial queue to synchronize access to `messages` - - let convo = try await fixtures.bobClient.conversations.newConversation(with: fixtures.alice.address) - let group = try await fixtures.bobClient.conversations.newGroup(with: [fixtures.alice.address]) - try await fixtures.aliceClient.conversations.sync() - - let streamingTask = Task(priority: .userInitiated) { - for try await _ in await fixtures.aliceClient.conversations.streamAllDecryptedMessages(includeGroups: true) { - messagesQueue.sync { - messages += 1 - } - } - } - - _ = try await group.send(content: "hi") - _ = try await convo.send(content: "hi") - - try await Task.sleep(nanoseconds: 1_000_000_000) - - streamingTask.cancel() - - messagesQueue.sync { - XCTAssertEqual(messages, 2) - } - - try await Task.sleep(nanoseconds: 1_000_000_000) - - _ = try await group.send(content: "hi") - _ = try await group.send(content: "hi") - _ = try await group.send(content: "hi") - _ = try await convo.send(content: "hi") - - try await Task.sleep(nanoseconds: 1_000_000_000) - - messagesQueue.sync { - XCTAssertEqual(messages, 2) - } - - let streamingTask2 = Task(priority: .userInitiated) { - for try await _ in await fixtures.aliceClient.conversations.streamAllDecryptedMessages(includeGroups: true) { - // Update the messages count in a thread-safe manner - messagesQueue.sync { - messages += 1 - } - } - } - - _ = try await group.send(content: "hi") - _ = try await convo.send(content: "hi") - - try await Task.sleep(nanoseconds: 1_000_000_000) - - messagesQueue.sync { - XCTAssertEqual(messages, 4) - } - } } diff --git a/Tests/XMTPTests/IntegrationTests.swift b/Tests/XMTPTests/IntegrationTests.swift deleted file mode 100644 index 835e374a..00000000 --- a/Tests/XMTPTests/IntegrationTests.swift +++ /dev/null @@ -1,609 +0,0 @@ -// -// IntegrationTests.swift -// -// -// Created by Pat Nakajima on 11/17/22. -// - -import Foundation -import secp256k1 -import web3 -import XCTest -import LibXMTP -@testable import XMTPiOS -import LibXMTP -import XMTPTestHelpers - -@available(macOS 13.0, *) -@available(iOS 16, *) -final class IntegrationTests: XCTestCase { - func testSaveKey() async throws { - let alice = try PrivateKey.generate() - let identity = try PrivateKey.generate() - - let authorized = try await alice.createIdentity(identity) - - let authToken = try await authorized.createAuthToken() - - let rustClient = try await LibXMTP.createV2Client(host: XMTPEnvironment.local.url, isSecure: false) - let api = try GRPCApiClient(environment: .local, secure: false, rustClient: rustClient) - api.setAuthToken(authToken) - - let encryptedBundle = try await authorized.toBundle.encrypted(with: alice) - - var envelope = Envelope() - envelope.contentTopic = Topic.userPrivateStoreKeyBundle(authorized.address).description - envelope.timestampNs = UInt64(Date().millisecondsSinceEpoch) * 1_000_000 - envelope.message = try encryptedBundle.serializedData() - - try await api.publish(envelopes: [envelope]) - - try await Task.sleep(nanoseconds: 2_000_000_000) - - let result = try await api.query(topic: .userPrivateStoreKeyBundle(authorized.address)) - XCTAssert(result.envelopes.count == 1) - } - - func testPublishingAndFetchingContactBundles() async throws { - let opts = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - - let aliceWallet = try PrivateKey.generate() - let alice = try await Client.create(account: aliceWallet, options: opts) - try await delayToPropagate() - let contact = try await alice.getUserContact(peerAddress: alice.address) - - XCTAssertEqual(contact!.v2.keyBundle.identityKey.secp256K1Uncompressed, try alice.v1keys.identityKey.publicKey.secp256K1Uncompressed) - XCTAssert(contact!.v2.keyBundle.identityKey.hasSignature == true, "no signature") - XCTAssert(contact!.v2.keyBundle.preKey.hasSignature == true, "pre key not signed") - - let aliceAgain = try await Client.create(account: aliceWallet, options: opts) - try await delayToPropagate() - let contactAgain = try await alice.getUserContact(peerAddress: alice.address) - XCTAssertEqual(contactAgain!, contact!, "contact bundle should not have changed") - } - - func testCanReceiveV1MessagesFromJS() async throws { - throw XCTSkip("integration only (requires local node)") - - let wallet = try FakeWallet.generate() - let options = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let client = try await Client.create(account: wallet, options: options) - - let convo = ConversationV1(client: client, peerAddress: "0xf4BF19Ed562651837bc11ff975472ABd239D35B5", sentAt: Date()) - try await convo.send(content: "hello from swift") - try await Task.sleep(for: .seconds(1)) - - let messages = try await convo.messages() - XCTAssertEqual(2, messages.count) - - XCTAssertEqual("HI \(wallet.address)", messages[0].body) - } - - func testCanReceiveV2MessagesFromJS() async throws { - throw XCTSkip("integration only (requires local node)") - - let wallet = try PrivateKey.generate() - let options = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let client = try await Client.create(account: wallet, options: options) - - try await client.publishUserContact() - - guard case let .v2(convo) = try? await client.conversations.newConversation(with: "0xf4BF19Ed562651837bc11ff975472ABd239D35B5", context: InvitationV1.Context(conversationID: "https://example.com/4")) else { - XCTFail("did not get v2 convo") - return - } - - try await convo.send(content: "hello from swift") - try await Task.sleep(for: .seconds(1)) - - let messages = try await convo.messages() - XCTAssertEqual(2, messages.count) - XCTAssertEqual("HI \(wallet.address)", messages[0].body) - } - - func testEndToEndConversation() async throws { - let opt = ClientOptions(api: .init(env: .local, isSecure: false)) - let alice = try await Client.create(account: try PrivateKey.generate(), options: opt) - let bob = try await Client.create(account: try PrivateKey.generate(), options: opt) - - let aliceConvo = try await alice.conversations.newConversation(with: bob.address) - _ = try await aliceConvo.send(text: "Hello Bob") - try await delayToPropagate() - - let bobConvos = try await bob.conversations.list() - let bobConvo = bobConvos[0] - let bobSees = try await bobConvo.messages() - XCTAssertEqual("Hello Bob", bobSees[0].body) - - try await bobConvo.send(text: "Oh, hello Alice") - try await delayToPropagate() - - let aliceSees = try await aliceConvo.messages() - XCTAssertEqual("Hello Bob", aliceSees[1].body) - XCTAssertEqual("Oh, hello Alice", aliceSees[0].body) - } - - func testUsingSavedCredentialsAndKeyMaterial() async throws { - let opt = ClientOptions(api: .init(env: .local, isSecure: false)) - let alice = try await Client.create(account: try PrivateKey.generate(), options: opt) - let bob = try await Client.create(account: try PrivateKey.generate(), options: opt) - - // Alice starts a conversation with Bob - let aliceConvo = try await alice.conversations.newConversation( - with: bob.address, - context: InvitationV1.Context.with { - $0.conversationID = "example.com/alice-bob-1" - $0.metadata["title"] = "Chatting Using Saved Credentials" - }) - _ = try await aliceConvo.send(text: "Hello Bob") - try await delayToPropagate() - - // Alice stores her credentials and conversations to her device - let keyBundle = try alice.privateKeyBundle.serializedData(); - let topicData = try aliceConvo.toTopicData().serializedData(); - - // Meanwhile, Bob sends a reply. - let bobConvos = try await bob.conversations.list() - let bobConvo = bobConvos[0] - try await bobConvo.send(text: "Oh, hello Alice") - try await delayToPropagate() - - // When Alice's device wakes up, it uses her saved credentials - let alice2 = try await Client.from( - bundle: PrivateKeyBundle(serializedData: keyBundle), - options: opt - ) - // And it uses the saved topic data for the conversation - let aliceConvo2 = try await alice2.conversations.importTopicData( - data: try Xmtp_KeystoreApi_V1_TopicMap.TopicData(serializedData: topicData)) - XCTAssertEqual("example.com/alice-bob-1", aliceConvo2.conversationID) - - // Now Alice should be able to load message using her saved key material. - let messages = try await aliceConvo2.messages() - XCTAssertEqual("Hello Bob", messages[1].body) - XCTAssertEqual("Oh, hello Alice", messages[0].body) - } - - func testDeterministicConversationCreation() async throws { - let opt = ClientOptions(api: .init(env: .local, isSecure: false)) - let alice = try await Client.create(account: try PrivateKey.generate(), options: opt) - let bob = try await Client.create(account: try PrivateKey.generate(), options: opt) - - // First Alice starts a conversation with Bob - let context = InvitationV1.Context.with { - $0.conversationID = "example.com/alice-bob-foo" - } - let c1 = try await alice.conversations.newConversation(with: bob.address, context: context) - _ = try await c1.send(text: "Hello Bob") - try await delayToPropagate() - - // Then Alice starts the same conversation (with Bob, same conversation ID) - let c2 = try await alice.conversations.newConversation(with: bob.address, context: context) - _ = try await c2.send(text: "And another one") - try await delayToPropagate() - - // Alice should see the same topic and keyMaterial for both conversations. - XCTAssertEqual(c1.topic, c2.topic) - XCTAssertEqual( - try c1.toTopicData().invitation.aes256GcmHkdfSha256.keyMaterial, - try c2.toTopicData().invitation.aes256GcmHkdfSha256.keyMaterial) - - // And Bob should only see the one conversation. - let bobConvos = try await bob.conversations.list() - XCTAssertEqual(1, bobConvos.count) - XCTAssertEqual(c1.topic, bobConvos[0].topic) - XCTAssertEqual("example.com/alice-bob-foo", bobConvos[0].conversationID) - - let bobMessages = try await bobConvos[0].messages() - XCTAssertEqual(2, bobMessages.count) - XCTAssertEqual("Hello Bob", bobMessages[1].body) - XCTAssertEqual("And another one", bobMessages[0].body) - } - - func testStreamMessagesInV1Conversation() async throws { - let opt = ClientOptions(api: .init(env: .local, isSecure: false)) - let alice = try await Client.create(account: try PrivateKey.generate(), options: opt) - let bob = try await Client.create(account: try PrivateKey.generate(), options: opt) - try await alice.publishUserContact(legacy: true) - try await bob.publishUserContact(legacy: true) - try await delayToPropagate() - - let aliceConversation = try await alice.conversations.newConversation(with: bob.address) - try await aliceConversation.send(content: "greetings") - try await delayToPropagate() - - let transcript = TestTranscript() - - let bobConversation = try await bob.conversations.newConversation(with: alice.address) - - XCTAssertEqual(bobConversation.topic.description, aliceConversation.topic.description) - - Task(priority: .userInitiated) { - for try await message in bobConversation.streamMessages() { - await transcript.add(message.body) - } - } - - try await aliceConversation.send(content: "hi bob") - try await delayToPropagate() - try await bobConversation.send(content: "hi alice") - try await delayToPropagate() - - let messages = await transcript.messages - XCTAssertEqual("hi bob", messages[0]) - XCTAssertEqual("hi alice", messages[1]) - } - - func testStreamMessagesInV2Conversation() async throws { - let alice = try PrivateKey.generate() - let bob = try PrivateKey.generate() - - let clientOptions = ClientOptions(api: .init(env: .local, isSecure: false)) - let aliceClient = try await Client.create(account: alice, options: clientOptions) - let bobClient = try await Client.create(account: bob, options: clientOptions) - - let aliceConversation = try await aliceClient.conversations.newConversation(with: bob.walletAddress, context: .init(conversationID: "https://example.com/3")) - - let transcript = TestTranscript() - - let bobConversation = try await bobClient.conversations.newConversation(with: alice.walletAddress, context: .init(conversationID: "https://example.com/3")) - - XCTAssertEqual(bobConversation.topic, aliceConversation.topic) - - Task(priority: .userInitiated) { - for try await message in bobConversation.streamMessages() { - await transcript.add(message.body) - } - } - try await aliceConversation.send(text: "hi bob") - try await delayToPropagate() - - let messages = await transcript.messages - XCTAssertEqual(1, messages.count) - XCTAssertEqual("hi bob", messages[0]) - } - - func testStreamEphemeralInV1Conversation() async throws { - let alice = try PrivateKey.generate() - let bob = try PrivateKey.generate() - - let clientOptions = ClientOptions(api: .init(env: .local, isSecure: false)) - let aliceClient = try await Client.create(account: alice, options: clientOptions) - try await aliceClient.publishUserContact(legacy: true) - let bobClient = try await Client.create(account: bob, options: clientOptions) - try await bobClient.publishUserContact(legacy: true) - - let expectation = expectation(description: "bob gets a streamed message") - - let convo = ConversationV1(client: bobClient, peerAddress: alice.address, sentAt: Date()) - - Task(priority: .userInitiated) { - for try await _ in convo.streamEphemeral() { - expectation.fulfill() - } - } - - try await convo.send(content: "hi", options: .init(ephemeral: true)) - - let messages = try await convo.messages() - XCTAssertEqual(0, messages.count) - - await waitForExpectations(timeout: 3) - } - - func testStreamEphemeralInV2Conversation() async throws { - let alice = try PrivateKey.generate() - let bob = try PrivateKey.generate() - - let clientOptions = ClientOptions(api: .init(env: .local, isSecure: false)) - let aliceClient = try await Client.create(account: alice, options: clientOptions) - let bobClient = try await Client.create(account: bob, options: clientOptions) - - let aliceConversation = try await aliceClient.conversations.newConversation(with: bob.walletAddress, context: .init(conversationID: "https://example.com/3")) - - let expectation = expectation(description: "bob gets a streamed message") - - guard case let .v2(bobConversation) = try await - bobClient.conversations.newConversation(with: alice.walletAddress, context: .init(conversationID: "https://example.com/3")) - else { - XCTFail("Did not create v2 convo") - return - } - - XCTAssertEqual(bobConversation.topic, aliceConversation.topic) - - Task(priority: .userInitiated) { - for try await _ in bobConversation.streamEphemeral() { - expectation.fulfill() - } - } - - try await aliceConversation.send(content: "hi", options: .init(ephemeral: true)) - - let messages = try await aliceConversation.messages() - XCTAssertEqual(0, messages.count) - - await waitForExpectations(timeout: 3) - } - - func testCanPaginateV1Messages() async throws { - try TestConfig.skipIfNotRunningLocalNodeTests() - - let bob = try FakeWallet.generate() - let alice = try FakeWallet.generate() - - let options = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let bobClient = try await Client.create(account: bob, options: options) - - // Publish alice's contact - _ = try await Client.create(account: alice, options: options) - - let convo = ConversationV1(client: bobClient, peerAddress: alice.address, sentAt: Date()) - - // Say this message is sent in the past - try await convo.send(content: "first") - try await delayToPropagate() - try await convo.send(content: "second") - try await delayToPropagate() - - var messages = try await convo.messages(limit: 1) - XCTAssertEqual(1, messages.count) - XCTAssertEqual("second", messages[0].body) // most-recent first - let secondMessageSent = messages[0].sent -// -// messages = try await convo.messages(limit: 1, before: secondMessageSent) -// XCTAssertEqual(1, messages.count) -// XCTAssertEqual("first", messages[0].body) -// let firstMessageSent = messages[0].sent -// -// messages = try await convo.messages(limit: 1, after: firstMessageSent) -// XCTAssertEqual(1, messages.count) -// XCTAssertEqual("second", messages[0].body) - } - - func testCanPaginateV2Messages() async throws { - let bob = try FakeWallet.generate() - let alice = try FakeWallet.generate() - - let options = ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false)) - let bobClient = try await Client.create(account: bob, options: options) - - // Publish alice's contact - _ = try await Client.create(account: alice, options: options) - - guard case let .v2(convo) = try await bobClient.conversations.newConversation(with: alice.address) else { - XCTFail("Did not get a v2 convo") - return - } - - // Say this message is sent in the past - let tenSecondsAgo = Date().addingTimeInterval(-10) - try await convo.send(content: "10 seconds ago", sentAt: tenSecondsAgo) - try await convo.send(content: "now") - - let messages = try await convo.messages(limit: 10) - XCTAssertEqual(2, messages.count) - let nowMessage = messages[0] - XCTAssertEqual("now", nowMessage.body) - - let messages2 = try await convo.messages(limit: 1, before: nowMessage.sent) - XCTAssertEqual(1, messages2.count) - let tenSecondsAgoMessage = messages2[0] - XCTAssertEqual("10 seconds ago", tenSecondsAgoMessage.body) - - let messages3 = try await convo.messages(limit: 1, after: tenSecondsAgoMessage.sent) - XCTAssertEqual(1, messages3.count) - let nowMessage2 = messages3[0] - XCTAssertEqual("now", nowMessage2.body) - - let messagesAsc = try await convo.messages(direction: .ascending) - XCTAssertEqual("10 seconds ago", messagesAsc[0].body) - - let messagesDesc = try await convo.messages(direction: .descending) - XCTAssertEqual("now", messagesDesc[0].body) - } - - func testStreamingMessagesShouldBeReceived() async throws { - let alice = try await Client.create(account: try FakeWallet.generate(), - options: ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false))) - let bob = try await Client.create(account: try FakeWallet.generate(), - options: ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false))) - let transcript = TestTranscript() - Task(priority: .userInitiated) { - for try await message in try await alice.conversations.streamAllMessages() { - await transcript.add(message.body) - } - } - let c1 = try await bob.conversations.newConversation(with: alice.address) - try await delayToPropagate() - _ = try await c1.send(text: "hello Alice") - try await delayToPropagate() - let messages = await transcript.messages - XCTAssertEqual(1, messages.count) - XCTAssertEqual("hello Alice", messages[0]) - } - - func testListingConversations() async throws { - let alice = try await Client.create(account: try FakeWallet.generate(), - options: ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false))) - let bob = try await Client.create(account: try FakeWallet.generate(), - options: ClientOptions(api: ClientOptions.Api(env: .local, isSecure: false))) - - let c1 = try await bob.conversations.newConversation( - with: alice.address, - context: InvitationV1.Context.with { - $0.conversationID = "example.com/alice-bob-1" - $0.metadata["title"] = "First Chat" - }) - try await c1.send(text: "hello Alice!") - try await delayToPropagate() - - var aliceConvoList = try await alice.conversations.list() - XCTAssertEqual(1, aliceConvoList.count) - XCTAssertEqual("example.com/alice-bob-1", aliceConvoList[0].conversationID) - - let c2 = try await bob.conversations.newConversation( - with: alice.address, - context: InvitationV1.Context.with { - $0.conversationID = "example.com/alice-bob-2" - $0.metadata["title"] = "Second Chat" - }) - try await c2.send(text: "hello again Alice!") - try await delayToPropagate() - - aliceConvoList = try await alice.conversations.list() - XCTAssertEqual(2, aliceConvoList.count) -// XCTAssertEqual("example.com/alice-bob-2", aliceConvoList[0].conversationID) -// XCTAssertEqual("example.com/alice-bob-1", aliceConvoList[1].conversationID) - } - - // Test used to verify https://github.com/xmtp/xmtp-ios/issues/39 fix. - func testExistingWallet() async throws { - throw XCTSkip("manual only (requires dev network)") - - // Generated from JS script - let keyBytes = Data([ - 31, 116, 198, 193, 189, 122, 19, 254, - 191, 189, 211, 215, 255, 131, 171, 239, - 243, 33, 4, 62, 143, 86, 18, 195, - 251, 61, 128, 90, 34, 126, 219, 236, - ]) - - var key = PrivateKey() - key.secp256K1.bytes = Data(keyBytes) - key.publicKey.secp256K1Uncompressed.bytes = Data(try LibXMTP.publicKeyFromPrivateKeyK256(privateKeyBytes: keyBytes)) - - let client = try await XMTPiOS.Client.create(account: key) - XCTAssertEqual(client.environment, .dev) - - let conversations = try await client.conversations.list() - XCTAssertEqual(1, conversations.count) - - let message = try await conversations[0].messages().first - XCTAssertEqual(message?.body, "hello") - } - - func testCanStreamV2Conversations() async throws { - let alice = try PrivateKey.generate() - let bob = try PrivateKey.generate() - - let clientOptions = ClientOptions(api: .init(env: .local, isSecure: false)) - let aliceClient = try await Client.create(account: alice, options: clientOptions) - let bobClient = try await Client.create(account: bob, options: clientOptions) - - let expectation1 = expectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - - Task(priority: .userInitiated) { - for try await convo in try await bobClient.conversations.stream() { - expectation1.fulfill() - } - } - - guard case let .v2(conversation) = try await bobClient.conversations.newConversation(with: alice.walletAddress) else { - XCTFail("Did not create a v2 convo") - return - } - - try await conversation.send(content: "hi") - - guard case let .v2(conversation) = try await bobClient.conversations.newConversation(with: alice.walletAddress) else { - XCTFail("Did not create a v2 convo") - return - } - - try await conversation.send(content: "hi again") - - let newWallet = try PrivateKey.generate() - let newClient = try await Client.create(account: newWallet, options: clientOptions) - - guard case let .v2(conversation2) = try await bobClient.conversations.newConversation(with: newWallet.walletAddress) else { - XCTFail("Did not create a v2 convo") - return - } - - try await conversation2.send(content: "hi from new wallet") - - await waitForExpectations(timeout: 3) - } - - func testCanReadGzipCompressedMessages() async throws { - throw XCTSkip("manual only (requires dev network)") - - let keyBytes = Data([ - 225, 2, 36, 98, 37, 243, 68, 234, - 42, 126, 248, 246, 126, 83, 186, 197, - 204, 186, 19, 173, 51, 0, 64, 0, - 155, 8, 249, 247, 163, 185, 124, 159, - ]) - - var key = PrivateKey() - key.secp256K1.bytes = Data(keyBytes) - key.publicKey.secp256K1Uncompressed.bytes = Data(try LibXMTP.publicKeyFromPrivateKeyK256(privateKeyBytes: keyBytes)) - - let client = try await XMTPiOS.Client.create(account: key) - XCTAssertEqual(client.environment, .dev) - - let convo = try await client.conversations.list()[0] - let message = try await convo.messages()[0] - - XCTAssertEqual("hello gzip", try message.content()) - } - - func testCanReadZipCompressedMessages() async throws { - throw XCTSkip("manual only (requires dev network)") - - let keyBytes = Data([ - 60, 45, 240, 192, 223, 2, 14, 166, - 122, 65, 231, 31, 122, 178, 158, 137, - 192, 97, 139, 83, 133, 245, 149, 250, - 25, 125, 25, 11, 203, 97, 12, 200, - ]) - - var key = PrivateKey() - key.secp256K1.bytes = Data(keyBytes) - key.publicKey.secp256K1Uncompressed.bytes = Data(try LibXMTP.publicKeyFromPrivateKeyK256(privateKeyBytes: keyBytes)) - - let client = try await XMTPiOS.Client.create(account: key) - XCTAssertEqual(client.environment, .dev) - - let convo = try await client.conversations.list()[0] - let message = try await convo.messages().last! - - let swiftdata = Data("hello deflate".utf8) as NSData - print("swift version: \((try swiftdata.compressed(using: .zlib) as Data).bytes)") - - XCTAssertEqual("hello deflate", try message.content()) - - // Check that we can send as well - try await convo.send(text: "hello deflate from swift again", options: .init(compression: .deflate)) - } - - func testCanLoadAllConversations() async throws { - throw XCTSkip("manual only (requires dev network)") - - let keyBytes = Data([ - 105, 207, 193, 11, 240, 115, 115, 204, - 117, 134, 201, 10, 56, 59, 52, 90, - 229, 103, 15, 66, 20, 113, 118, 137, - 44, 62, 130, 90, 30, 158, 182, 178, - ]) - - var key = PrivateKey() - key.secp256K1.bytes = Data(keyBytes) - key.publicKey.secp256K1Uncompressed.bytes = Data(try LibXMTP.publicKeyFromPrivateKeyK256(privateKeyBytes: keyBytes)) - - - let client = try await XMTPiOS.Client.create(account: key) - - let conversations = try await client.conversations.list() - - XCTAssertEqual(200, conversations.count) - } - - // Helpers - - func delayToPropagate() async throws { - try await Task.sleep(for: .milliseconds(500)) - } -} diff --git a/Tests/XMTPTests/InvitationTests.swift b/Tests/XMTPTests/InvitationTests.swift deleted file mode 100644 index aa025d76..00000000 --- a/Tests/XMTPTests/InvitationTests.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// InvitationTests.swift -// -// -// Created by Pat Nakajima on 11/27/22. -// - -import Foundation -import XCTest -@testable import XMTPiOS -import XMTPTestHelpers - -@available(iOS 16.0, *) -class InvitationTests: XCTestCase { - - func testDeterministicInvite() async throws { - let aliceWallet = try FakeWallet.generate() - let bobWallet = try FakeWallet.generate() - - let alice = try await PrivateKeyBundleV1.generate(wallet: aliceWallet) - let bob = try await PrivateKeyBundleV1.generate(wallet: bobWallet) - - let makeInvite = { (conversationID: String) in - try InvitationV1.createDeterministic( - sender: alice.toV2(), - recipient: bob.toV2().getPublicKeyBundle(), - context: InvitationV1.Context.with { - $0.conversationID = conversationID - }) - } - - // Repeatedly making the same invite should use the same topic/keys - let original = try makeInvite("example.com/conversation-foo"); - for i in 1...10 { - let invite = try makeInvite("example.com/conversation-foo"); - XCTAssertEqual(original.topic, invite.topic); - } - - // But when the conversationId changes then it use a new topic/keys - let invite = try makeInvite("example.com/conversation-bar"); - XCTAssertNotEqual(original.topic, invite.topic); - } - - func testGenerateSealedInvitation() async throws { - let aliceWallet = try FakeWallet.generate() - let bobWallet = try FakeWallet.generate() - - let alice = try await PrivateKeyBundleV1.generate(wallet: aliceWallet) - let bob = try await PrivateKeyBundleV1.generate(wallet: bobWallet) - - let invitation = try InvitationV1.createDeterministic( - sender: alice.toV2(), - recipient: bob.toV2().getPublicKeyBundle() - ) - - let newInvitation = try SealedInvitation.createV1( - sender: try alice.toV2(), - recipient: try bob.toV2().getPublicKeyBundle(), - created: Date(), - invitation: invitation - ) - - let deserialized = try SealedInvitation(serializedData: try newInvitation.serializedData()) - - XCTAssert(!deserialized.v1.headerBytes.isEmpty, "header bytes empty") - XCTAssertEqual(newInvitation, deserialized) - - let header = newInvitation.v1.header - - // Ensure the headers haven't been mangled - XCTAssertEqual(header.sender, try alice.toV2().getPublicKeyBundle()) - XCTAssertEqual(header.recipient, try bob.toV2().getPublicKeyBundle()) - - // Ensure alice can decrypt the invitation - let aliceInvite = try newInvitation.v1.getInvitation(viewer: try alice.toV2()) - XCTAssertEqual(aliceInvite.topic, invitation.topic) - XCTAssertEqual(aliceInvite.aes256GcmHkdfSha256.keyMaterial, invitation.aes256GcmHkdfSha256.keyMaterial) - - // Ensure bob can decrypt the invitation - let bobInvite = try newInvitation.v1.getInvitation(viewer: try bob.toV2()) - XCTAssertEqual(bobInvite.topic, invitation.topic) - XCTAssertEqual(bobInvite.aes256GcmHkdfSha256.keyMaterial, invitation.aes256GcmHkdfSha256.keyMaterial) - } - - func testGeneratesKnownDeterministicTopic() async throws { - // address = 0xF56d1F3b1290204441Cb3843C2Cac1C2f5AEd690 - let aliceKeyData = Data(("0x0a8a030ac20108c192a3f7923112220a2068d2eb2ef8c50c4916b42ce638c5610e44ff4eb3ecb098" + - "c9dacf032625c72f101a940108c192a3f7923112460a440a40fc9822283078c323c9319c45e60ab4" + - "2c65f6e1744ed8c23c52728d456d33422824c98d307e8b1c86a26826578523ba15fe6f04a17fca17" + - "6664ee8017ec8ba59310011a430a410498dc2315dd45d99f5e900a071e7b56142de344540f07fbc7" + - "3a0f9a5d5df6b52eb85db06a3825988ab5e04746bc221fcdf5310a44d9523009546d4bfbfbb89cfb" + - "12c20108eb92a3f7923112220a20788be9da8e1a1a08b05f7cbf22d86980bc056b130c482fa5bd26" + - "ccb8d29b30451a940108eb92a3f7923112460a440a40a7afa25cb6f3fbb98f9e5cd92a1df1898452" + - "e0dfa1d7e5affe9eaf9b72dd14bc546d86c399768badf983f07fa7dd16eee8d793357ce6fccd6768" + - "07d87bcc595510011a430a410422931e6295c3c93a5f6f5e729dc02e1754e916cb9be16d36dc163a" + - "300931f42a0cd5fde957d75c2068e1980c5f86843daf16aba8ae57e8160b8b9f0191def09e").web3.bytesFromHex!) - let aliceKeys = try PrivateKeyBundle(serializedData: aliceKeyData).v1.toV2() - - // address = 0x3De402A325323Bb97f00cE3ad5bFAc96A11F9A34 - let bobKeyData = Data(("0x0a88030ac001088cd68df7923112220a209057f8d813314a2aae74e6c4c30f909c1c496b6037ce32" + - "a12c613558a8e961681a9201088cd68df7923112440a420a40501ae9b4f75d5bb5bae3ca4ecfda4e" + - "de9edc5a9b7fc2d56dc7325b837957c23235cc3005b46bb9ef485f106404dcf71247097ed5096355" + - "90f4b7987b833d03661a430a4104e61a7ae511567f4a2b5551221024b6932d6cdb8ecf3876ec64cf" + - "29be4291dd5428fc0301963cdf6939978846e2c35fd38fcb70c64296a929f166ef6e4e91045712c2" + - "0108b8d68df7923112220a2027707399474d417bf6aae4baa3d73b285bf728353bc3e156b0e32461" + - "ebb48f8c1a940108b8d68df7923112460a440a40fb96fa38c3f013830abb61cf6b39776e0475eb13" + - "79c66013569c3d2daecdd48c7fbee945dcdbdc5717d1f4ffd342c4d3f1b7215912829751a94e3ae1" + - "1007e0a110011a430a4104952b7158cfe819d92743a4132e2e3ae867d72f6a08292aebf471d0a7a2" + - "907f3e9947719033e20edc9ca9665874bd88c64c6b62c01928065f6069c5c80c699924").web3.bytesFromHex!) - let bobKeys = try PrivateKeyBundle(serializedData: bobKeyData) - - let aliceInvite = try InvitationV1.createDeterministic(sender: aliceKeys, recipient: bobKeys.v1.toV2().getPublicKeyBundle(), context: InvitationV1.Context.with { $0.conversationID = "test" }) - - XCTAssertEqual(aliceInvite.topic, "/xmtp/0/m-4b52be1e8567d72d0bc407debe2d3c7fca2ae93a47e58c3f9b5c5068aff80ec5/proto") - - let bobInvite = try InvitationV1.createDeterministic(sender: bobKeys.v1.toV2(), recipient: aliceKeys.getPublicKeyBundle(), context: InvitationV1.Context.with { $0.conversationID = "test" }) - - XCTAssertEqual(bobInvite.topic, "/xmtp/0/m-4b52be1e8567d72d0bc407debe2d3c7fca2ae93a47e58c3f9b5c5068aff80ec5/proto") - } - - func testCreatesDeterministicTopicsBidirectionally() async throws { - let aliceWallet = try FakeWallet.generate() - let bobWallet = try FakeWallet.generate() - - let alice = try await PrivateKeyBundleV1.generate(wallet: aliceWallet) - let bob = try await PrivateKeyBundleV1.generate(wallet: bobWallet) - - let aliceInvite = try InvitationV1.createDeterministic( - sender: alice.toV2(), - recipient: bob.toV2().getPublicKeyBundle() - ) - - let bobInvite = try InvitationV1.createDeterministic( - sender: bob.toV2(), - recipient: alice.toV2().getPublicKeyBundle() - ) - - let aliceSharedSecret = try alice.sharedSecret(peer: bob.toPublicKeyBundle(), myPreKey: alice.preKeys[0].publicKey, isRecipient: false) - - let bobSharedSecret = try bob.sharedSecret(peer: alice.toPublicKeyBundle(), myPreKey: bob.preKeys[0].publicKey, isRecipient: true) - - XCTAssertEqual(aliceSharedSecret.bytes, bobSharedSecret.bytes) - - XCTAssertEqual(aliceInvite.topic, bobInvite.topic) - - } -} diff --git a/Tests/XMTPTests/MessageTests.swift b/Tests/XMTPTests/MessageTests.swift deleted file mode 100644 index ad31345e..00000000 --- a/Tests/XMTPTests/MessageTests.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// MessageTests.swift -// -// -// Created by Pat Nakajima on 11/27/22. -// - -import CryptoKit -import XCTest -import LibXMTP -@testable import XMTPiOS -import XMTPTestHelpers - -@available(iOS 16.0, *) -class MessageTests: XCTestCase { - func testFullyEncodesDecodesMessagesV1() async throws { - for _ in 0 ... 10 { - let aliceWallet = try PrivateKey.generate() - let bobWallet = try PrivateKey.generate() - - let alice = try await PrivateKeyBundleV1.generate(wallet: aliceWallet) - let bob = try await PrivateKeyBundleV1.generate(wallet: bobWallet) - - let content = Data("Yo!".utf8) - let message1 = try MessageV1.encode( - sender: alice, - recipient: bob.toPublicKeyBundle(), - message: content, - timestamp: Date() - ) - - XCTAssertEqual(aliceWallet.walletAddress, message1.senderAddress) - XCTAssertEqual(bobWallet.walletAddress, message1.recipientAddress) - - let decrypted = try message1.decrypt(with: alice) - XCTAssertEqual(decrypted, content) - -// let message2 = try MessageV1(serializedData: try message1.serializedData()) -// let message2Decrypted = try message2.decrypt(with: alice) -// XCTAssertEqual(message2.senderAddress, aliceWallet.walletAddress) -// XCTAssertEqual(message2.recipientAddress, bobWallet.walletAddress) -// XCTAssertEqual(message2Decrypted, content) - } - } - - func testFullyEncodesDecodesMessagesV2() async throws { - try TestConfig.skipIfNotRunningLocalNodeTests() - let aliceWallet = try PrivateKey.generate() - let bobWallet = try PrivateKey.generate() - - let alice = try await PrivateKeyBundleV1.generate(wallet: aliceWallet) - let bob = try await PrivateKeyBundleV1.generate(wallet: bobWallet) - - let client = try await Client.create(account: aliceWallet) - var invitationContext = InvitationV1.Context() - invitationContext.conversationID = "https://example.com/1" - - let invitationv1 = try InvitationV1.createDeterministic( - sender: alice.toV2(), - recipient: bob.toV2().getPublicKeyBundle(), - context: invitationContext - ) - let sealedInvitation = try SealedInvitation.createV1(sender: alice.toV2(), recipient: bob.toV2().getPublicKeyBundle(), created: Date(), invitation: invitationv1) - let encoder = TextCodec() - let encodedContent = try encoder.encode(content: "Yo!", client: client) - let message1 = try await MessageV2.encode(client: client, content: encodedContent, topic: invitationv1.topic, keyMaterial: invitationv1.aes256GcmHkdfSha256.keyMaterial, codec: encoder) - - let decoded = try MessageV2.decode("", "", message1, keyMaterial: invitationv1.aes256GcmHkdfSha256.keyMaterial, client: client) - let result: String = try decoded.content() - XCTAssertEqual(result, "Yo!") - } - - func testCanDecrypt() throws { - // All of these values were generated from xmtp-js - let content = "0a120a08786d74702e6f7267120474657874180112110a08656e636f64696e6712055554462d3822026869".web3.bytesFromHex! - let salt = "48c6c40ce9998a8684937b2bd90c492cef66c9cd92b4a30a4f811b43fd0aed79".web3.bytesFromHex! - let nonce = "31f78d2c989a37d8471a5d40".web3.bytesFromHex! - let secret = "04c86317929a0c223f44827dcf1290012b5e6538a54282beac85c2b16062fc8f781b52bea90e8c7c028254c6ba57ac144a56f054d569c340e73c6ff37aee4e68fc04a0fdb4e9c404f5d246a9fe2308f950f8374b0696dd98cc1c97fcbdbc54383ac862abee69c107723e1aa809cfbc587253b943476dc89c126af4f6515161a826ca04801742d6c45ee150a28f80cbcffd78a0210fe73ffdd74e4af8fd6307fb3d622d873653ca4bd47deb4711ef02611e5d64b4bcefcc481e236979af2b6156863e68".web3.bytesFromHex! - let payload = "d752fb09ee0390fe5902a1bd7b2f530da7e5b3a2bd91bad9df8fa284ab63327b86a59620fd3e2d2cf9183f46bd0fe75bda3caca893420c38416b1f".web3.bytesFromHex! - let additionalData = "0aac020a940108d995eeadcc3012460a440a408f20c9fc03909edeb21538b0a568c423f8829e95c0270779ca704f72a45f02416f6071f6faaf421cac3bacc6bb432fc4b5f92bc4391349953c7c98f12253cdd710011a430a4104b7eb7b56059a4f08bf3dd8f1b329e21d486e39822f17db15bad0d7f689f6c8081ae2800b9014fc9ef355a39e10503fddfdfa0b07ccc1946c2275b10e660d5ded12920108e995eeadcc3012440a420a40da669aa014468ffe34d5b962443d8b1e353b1e39f252bbcffa5c6c70adf9f7d2484de944213f345bac869e8c1942657b9c59f6fc12d139171b22789bc76ffb971a430a4104901d3a7f728bde1f871bcf46d44dcf34eead4c532135913583268d35bd93ca0a1571a8cb6546ab333f2d77c3bb9839be7e8f27795ea4d6e979b6670dec20636d12aa020a920108bad3eaadcc3012440a420a4016d83a6e44ee8b9764f18fbb390f2a4049d92ff904ebd75c76a71d58a7f943744f8bed7d3696f9fb41ce450c5ab9f4a7f9a83e3d10f401bbe85e3992c5156d491a430a41047cebe3a23e573672363665d13220d368d37776e10232de9bd382d5af36392956dbd806f8b78bec5cdc111763e4ef4aff7dee65a8a15fee8d338c387320c5b23912920108bad3eaadcc3012440a420a404a751f28001f34a4136529a99e738279856da6b32a1ee9dba20849d9cd84b6165166a6abeae1139ed8df8be3b4594d9701309075f2b8d5d4de1f713fb62ae37e1a430a41049c45e552ac9f69c083bd358acac31a2e3cf7d9aa9298fef11b43252730949a39c68272302a61b548b13452e19272c119b5189a5d7b5c3283a37d5d9db5ed0c6818b286deaecc30".web3.bytesFromHex! - - var ciphertext = CipherText() - ciphertext.aes256GcmHkdfSha256.gcmNonce = Data(nonce) - ciphertext.aes256GcmHkdfSha256.hkdfSalt = Data(salt) - ciphertext.aes256GcmHkdfSha256.payload = Data(payload) - - let decrypted = try Crypto.decrypt(Data(secret), ciphertext, additionalData: Data(additionalData)) - - XCTAssertEqual(Data(content), decrypted) - } - - func testGetsV2ID() async throws { - try TestConfig.skip(because: "run manually against dev") - let envelopeMessageData = Data( - "12bf040a470880dedf9dafc0ff9e17123b2f786d74702f302f6d2d32536b644e355161305a6d694649357433524662667749532d4f4c76356a7573716e6465656e544c764e672f70726f746f12f3030af0030a20439174a205643a50af33c7670341338526dbb9c1cf0560687ff8a742e957282d120c090ba2b385b40639867493ce1abd037648c947f72e5c62e8691d7748e78f9a346ff401c97a628ebecf627d722829ff9cfb7d7c3e0b9e26b5801f2b5a39fd58757cc5771427bfefad6243f52cfc84b384fa042873ebeb90948aa80ca34f26ff883d64720c9228ed6bcd1a5c46953a12ae8732fd70260651455674e2e2c23bc8d64ed35562fef4cdfc55d38e72ad9cf2d597e68f48b6909967b0f5d0b4f33c0af3efce55c739fbc93888d20b833df15811823970a356b26622936564d830434d3ecde9a013f7433142e366f1df5589131e440251be54d5d6deef9aaaa9facac26eb54fb7b74eb48c5a2a9a2e2956633b123cc5b91dec03e4dba30683be03bd7510f16103d3f81712dccf2be003f2f77f9e1f162bc47f6c1c38a1068abd3403952bef31d75e8024e7a62d9a8cbd48f1872a0156abb559d01de689b4370a28454658957061c46f47fc5594808d15753876d4b5408b3a3410d0555c016e427dfceae9c05a4a21fd7ce4cfbb11b2a696170443cf310e0083b0a48e357fc2f00c688c0b56821c8a14c2bb44ddfa31d680dfc85efe4811e86c6aa3adfc373ad5731ddab83960774d98d60075b8fd70228da5d748bfb7a5334bd07e1cc4a9fbf3d5de50860d0684bb27786b5b4e00d415".web3.bytesFromHex! - ) - - let envelope = try Envelope.with { envelope in - envelope.contentTopic = "/xmtp/0/m-2SkdN5Qa0ZmiFI5t3RFbfwIS-OLv5jusqndeenTLvNg/proto" - envelope.message = envelopeMessageData - envelope.timestampNs = UInt64(Date().millisecondsSinceEpoch) - } - - let key = try PrivateKey.with { key in - key.secp256K1.bytes = Data([ - 80, 84, 15, 126, 14, 105, 216, 8, - 61, 147, 153, 232, 103, 69, 219, 13, - 99, 118, 68, 56, 160, 94, 58, 22, - 140, 247, 221, 172, 14, 188, 52, 88, - ]) - - key.publicKey.secp256K1Uncompressed.bytes = try KeyUtilx.generatePublicKey(from: key.secp256K1.bytes) - } - - let keyBundleData = Data( - "0a86030ac001089387b882df3012220a204a393d6ac64c10770a2585def70329f10ca480517311f0b321a5cfbbae0119951a9201089387b882df3012440a420a4092f66532cf0266d146a17060fb64148e4a6adc673c14511e45f40ac66551234a336a8feb6ef3fabdf32ea259c2a3bca32b9550c3d34e004ea59e86b42f8001ac1a430a41041c919edda3399ab7f20f5e1a9339b1c2e666e80a164fb1c6d8bc1b7dbf2be158f87c837a6364c7fb667a40c2d234d198a7c2168a928d39409ad7d35d653d319912c00108a087b882df3012220a202ade2eefefa5f8855e557d685278e8717e3f57682b66c3d73aa87896766acddc1a920108a087b882df3012440a420a404f4a90ef10e1536e4588f12c2320229008d870d2abaecd1acfefe9ca91eb6f6d56b1380b1bdebdcf9c46fb19ceb3247d5d986a4dd2bce40a4bdf694c24b08fbb1a430a4104a51efe7833c46d2f683e2eb1c07811bb96ab5e4c2000a6f06124968e8842ff8be737ad7ca92b2dabb13550cdc561df15771c8494eca7b7ca5519f6da02f76489".web3.bytesFromHex! - ) - let keyBundle = try PrivateKeyBundle(serializedData: keyBundleData) - - let client = try await Client.from(bundle: keyBundle) - - let conversationJSON = Data(""" - {"version":"v2","topic":"/xmtp/0/m-2SkdN5Qa0ZmiFI5t3RFbfwIS-OLv5jusqndeenTLvNg/proto","keyMaterial":"ATA1L0O2aTxHmskmlGKCudqfGqwA1H+bad3W/GpGOr8=","peerAddress":"0x436D906d1339fC4E951769b1699051f020373D04","createdAt":"2023-01-26T22:58:45.068Z","context":{"conversationId":"pat/messageid","metadata":{}}} - """.utf8) - - let decoder = JSONDecoder() - guard case let .v2(decodedConversation) = try client.importConversation(from: conversationJSON) else { - XCTFail("did not get v2 conversation") - return - } - - let conversation = ConversationV2(topic: decodedConversation.topic, keyMaterial: decodedConversation.keyMaterial, context: InvitationV1.Context(), peerAddress: decodedConversation.peerAddress, client: client, header: SealedInvitationHeaderV1()) - - let decodedMessage = try conversation.decode(envelope: envelope) - XCTAssertEqual(decodedMessage.id, "e42a7dd44d0e1214824eab093cb89cfe6f666298d0af2d54fe0c914c8b72eff3") - } -} diff --git a/Tests/XMTPTests/PaginationTests.swift b/Tests/XMTPTests/PaginationTests.swift deleted file mode 100644 index 1936db96..00000000 --- a/Tests/XMTPTests/PaginationTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// PaginationTests.swift -// -// -// Created by Michael Xu on 05/16/23. -// - -import Foundation - -import XCTest -@testable import XMTPiOS -import LibXMTP -import XMTPTestHelpers - -@available(iOS 15, *) -class PaginationTests: XCTestCase { - - func newClientHelper(account: PrivateKey) async throws -> Client { - let client = try await Client.create(account: account, options: ClientOptions(api: .init(env: .local, isSecure: false))) - return client - } - - func testLongConvo() async throws { - let alice = try PrivateKey.generate() - let bob = try PrivateKey.generate() - - let aliceClient = try await newClientHelper(account: alice) - let bobClient = try await newClientHelper(account: bob) - - let canAliceMessageBob = try await aliceClient.canMessage(bobClient.address) - XCTAssert(canAliceMessageBob) - - // Start a conversation with alice - - guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - guard case let .v2(aliceConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for alice") - return - } - - try await bobConversation.send(content: "hey alice 1", sentAt: Date().addingTimeInterval(-1000)) - try await bobConversation.send(content: "hey alice 2", sentAt: Date().addingTimeInterval(-500)) - try await bobConversation.send(content: "hey alice 3", sentAt: Date()) - - let messages = try await aliceConversation.messages(limit: 1) - XCTAssertEqual(1, messages.count) - XCTAssertEqual("hey alice 3", messages[0].body) - - let messages2 = try await aliceConversation.messages(limit: 1, before: messages[0].sent) - XCTAssertEqual(1, messages2.count) - XCTAssertEqual("hey alice 2", messages2[0].body) - - // Send many many more messages, such that it forces cursor saving and pagination - for i in 4..<101 { - try await bobConversation.send(content: "hey alice \(i)", sentAt: Date()) - } - // Grab the messages 50 at a time - let messages3 = try await aliceConversation.messages(limit: 50) - XCTAssertEqual(50, messages3.count) - XCTAssertEqual("hey alice 100", messages3[0].body) - XCTAssertEqual("hey alice 51", messages3[49].body) - - let messages4 = try await aliceConversation.messages(limit: 100, before: messages3[49].sent) - XCTAssertEqual(50, messages4.count) - XCTAssertEqual("hey alice 50", messages4[0].body) - XCTAssertEqual("hey alice 1", messages4[49].body) - } - - func testCanStreamConversationsV2() async throws { - let alice = try PrivateKey.generate() - let bob = try PrivateKey.generate() - - // Need to upload Alice's contact bundle - let _ = try await newClientHelper(account: alice) - let bobClient = try await newClientHelper(account: bob) - let expectation1 = expectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - - Task(priority: .userInitiated) { - for try await _ in try await bobClient.conversations.stream() { - print("Got one conversation") - expectation1.fulfill() - } - } - - guard case let .v2(conversation) = try await bobClient.conversations.newConversation(with: alice.walletAddress) else { - XCTFail("Did not create a v2 convo") - return - } - - try await conversation.send(content: "hi") - - let newWallet = try PrivateKey.generate() - // Need to upload contact bundle - let _ = try await newClientHelper(account: newWallet) - guard case let .v2(conversation2) = try await bobClient.conversations.newConversation(with: newWallet.walletAddress) else { - XCTFail("Did not create a v2 convo") - return - } - - try await conversation2.send(content: "hi from new wallet") - - await waitForExpectations(timeout: 5) - - // Test that we can stream a few more messages - let expectation2 = expectation(description: "got follow-up messages") - expectation2.expectedFulfillmentCount = 5 - Task(priority: .userInitiated) { - for try await message in conversation.streamMessages() { - print("Got message: \(message)") - expectation2.fulfill() - } - } - - // Slowly send out messages - Task(priority: .userInitiated) { - try! await conversation.send(content: "hi") - try! await conversation.send(content: "hi again") - try! await conversation.send(content: "hi again again") - try! await conversation.send(content: "hi again again again") - try! await conversation.send(content: "hi again again again again") - } - - await waitForExpectations(timeout: 5) - } -} diff --git a/Tests/XMTPTests/PrivateKeyBundleTests.swift b/Tests/XMTPTests/PrivateKeyBundleTests.swift deleted file mode 100644 index 08108469..00000000 --- a/Tests/XMTPTests/PrivateKeyBundleTests.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// PrivateKeyBundleTests.swift -// -// -// Created by Pat Nakajima on 11/29/22. -// - -import secp256k1 -import XCTest -@testable import XMTPiOS - -class PrivateKeyBundleTests: XCTestCase { - func testConversion() async throws { - let wallet = try PrivateKey.generate() - let v1 = try await PrivateKeyBundleV1.generate(wallet: wallet) - - let v2 = try v1.toV2() - - let v2PreKeyPublic = try UnsignedPublicKey(serializedData: v2.preKeys[0].publicKey.keyBytes) - XCTAssertEqual(v1.preKeys[0].publicKey.secp256K1Uncompressed.bytes, v2PreKeyPublic.secp256K1Uncompressed.bytes) - } - - func testKeyBundlesAreSigned() async throws { - let wallet = try PrivateKey.generate() - let v1 = try await PrivateKeyBundleV1.generate(wallet: wallet) - - XCTAssert(v1.identityKey.publicKey.hasSignature, "no private v1 identity key signature") - XCTAssert(v1.preKeys[0].publicKey.hasSignature, "no private v1 pre key signature") - XCTAssert(v1.toPublicKeyBundle().identityKey.hasSignature, "no public v1 identity key signature") - XCTAssert(v1.toPublicKeyBundle().preKey.hasSignature, "no public v1 pre key signature") - - let v2 = try v1.toV2() - XCTAssert(v2.identityKey.publicKey.hasSignature, "no private v2 identity key signature") - XCTAssert(v2.preKeys[0].publicKey.hasSignature, "no private v2 pre key signature") - XCTAssert(v2.getPublicKeyBundle().identityKey.hasSignature, "no public v2 identity key signature") - XCTAssert(v2.getPublicKeyBundle().preKey.hasSignature, "no public v2 pre key signature") - } - - func testSharedSecret() async throws { - let alice = try PrivateKey.generate() - let alicePrivateBundle = try await PrivateKeyBundleV1.generate(wallet: alice).toV2() - let alicePublicBundle = alicePrivateBundle.getPublicKeyBundle() - - let bob = try PrivateKey.generate() - let bobPrivateBundle = try await PrivateKeyBundleV1.generate(wallet: bob).toV2() - let bobPublicBundle = bobPrivateBundle.getPublicKeyBundle() - - let aliceSharedSecret = try alicePrivateBundle.sharedSecret(peer: bobPublicBundle, myPreKey: alicePublicBundle.preKey, isRecipient: true) - - let bobSharedSecret = try bobPrivateBundle.sharedSecret(peer: alicePublicBundle, myPreKey: bobPublicBundle.preKey, isRecipient: false) - - XCTAssertEqual(aliceSharedSecret, bobSharedSecret) - } - - func testSharedSecretMatchesWhatJSGenerates() throws { - let meBundleData = Data("0a86030ac00108a687b5d8cc3012220a20db73e1b4b5aeffb6cecd37526d842327730433e1751bceb5824d937f779797541a920108a687b5d8cc3012440a420a40d35c081d9ab59b3fb13e27cb03a225c7134bc4ce4ce51f80273481c31d803e1e4fa8ae43e7ec20b06a81b694ad28470f85fc971b8050867f5a4821c03a67f0e81a430a410443631548a55a60f06989ce1bc3fa43fdbe463ea4748dcb509e09fc58514c6e56edfac83e1fff5f382bc110fa066762f4b862db8df53be7d48268b3fdf649adc812c00108b787b5d8cc3012220a209e2631f34af8fc1ec0f75bd15ee4e110ac424300f39bff26c7a990a75a49ac641a920108b787b5d8cc3012440a420a40202a68a2e95d446511ecf22f5487b998989989adfc0a60e1ce201e0bab64d836066ccda987cda99c0e588babb8c334a820d6a6e360100ba7ba08e0e339a303681a430a4104c9733798111d89446264db365bc0dde54b5f9202eeb309eec2f18c572ce11e267fe91e184207676d7af5eaf2ad65de0881093623030f6096ea5bf3ecd252c482".web3.bytesFromHex!) - - let youBundleData = Data("0a940108c487b5d8cc3012460a440a40c51e611e662117991b19f60b6a7f6d9f08671c3d55241e959954c2e0f2ec47d15b872986d2a279ffe55df01709b000fbdcc9e85c1946876e187f90a0fd32222c10011a430a41049cccf02f766f7d4c322eeb498f2ac0283a011992fc77f9e0d5687b826aafd48d8319f48f773ec959221bf7bf7d3da4b09e59af540a633c588df2f1b6f465d6a712940108cb87b5d8cc3012460a440a40b7b0e89ce4789f6e78502357864979abe9e26cd44a36ed75578368a02cdc3bda7d56721660cb2066b76a4a6dd5a78d99df4b096cc4622a2065cf05b2f32b94be10011a430a410438f2b23a4e0f9c61e716b8cf4b23f2709d92b4feb71429a385b6878c31085384701bc787def9396b441bfb8751c042432785c352f8ee9bfb9c6cd5d6871b2d1a".web3.bytesFromHex!) - - let secretData = Data("049f4cd17426f9dfac528f400db858a9cbc87488879d6df5bea3595beaeb37415f1b24227e571dd4969406f366841e682795f284b54952a22b2dcff87971580fa604c0a97d550ce3ce5dac2e5469a2e3ece7232d80247a789044ebef0478c6911d63400a13090de6e8aeb4a1bcb878ca73b1d7eb13ab3012e564cfef74a8182467cc047d999bb077e5b223509fab7a08642c29359b8c3144ffa30002e45f09e4a515927f682eb71b68bd52f498d5d464c6bb14d3c07aefc86a1ab8e2528a21ffd41912".web3.bytesFromHex!) - - let meBundle = try PrivateKeyBundle(serializedData: meBundleData).v1.toV2() - let youBundlePublic = try SignedPublicKeyBundle(try PublicKeyBundle(serializedData: youBundleData)) - - let secret = try meBundle.sharedSecret(peer: youBundlePublic, myPreKey: meBundle.preKeys[0].publicKey, isRecipient: true) - - XCTAssertEqual(secretData, secret) - } -} diff --git a/Tests/XMTPTests/ReactionTests.swift b/Tests/XMTPTests/ReactionTests.swift index f1d6e26d..d867701c 100644 --- a/Tests/XMTPTests/ReactionTests.swift +++ b/Tests/XMTPTests/ReactionTests.swift @@ -1,132 +1,132 @@ -// -// ReactionTests.swift -// -// -// Created by Naomi Plasterer on 7/26/23. -// - import Foundation - import XCTest + @testable import XMTPiOS @available(iOS 15, *) class ReactionTests: XCTestCase { - func testCanDecodeLegacyForm() async throws { - let codec = ReactionCodec() - - // This is how clients send reactions now. - let canonicalEncoded = EncodedContent.with { - $0.type = ContentTypeReaction - $0.content = Data(""" - { - "action": "added", - "content": "smile", - "reference": "abc123", - "schema": "shortcode" - } - """.utf8) - } - - // Previously, some clients sent reactions like this. - // So we test here to make sure we can still decode them. - let legacyEncoded = EncodedContent.with { - $0.type = ContentTypeReaction - $0.parameters = [ - "action": "added", - "reference": "abc123", - "schema": "shortcode", - ] - $0.content = Data("smile".utf8) - } - - let fixtures = await fixtures() - let canonical = try codec.decode(content: canonicalEncoded, client: fixtures.aliceClient) - let legacy = try codec.decode(content: legacyEncoded, client: fixtures.aliceClient) - - XCTAssertEqual(ReactionAction.added, canonical.action) - XCTAssertEqual(ReactionAction.added, legacy.action) - XCTAssertEqual("smile", canonical.content) - XCTAssertEqual("smile", legacy.content) - XCTAssertEqual("abc123", canonical.reference) - XCTAssertEqual("abc123", legacy.reference) - XCTAssertEqual(ReactionSchema.shortcode, canonical.schema) - XCTAssertEqual(ReactionSchema.shortcode, legacy.schema) - } - - func testCanUseReactionCodec() async throws { - let fixtures = await fixtures() - let conversation = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) - - fixtures.aliceClient.register(codec: ReactionCodec()) - - try await conversation.send(text: "hey alice 2 bob") - - let messageToReact = try await conversation.messages()[0] - - let reaction = Reaction( - reference: messageToReact.id, - action: .added, - content: "U+1F603", - schema: .unicode - ) - - try await conversation.send( - content: reaction, - options: .init(contentType: ContentTypeReaction) - ) - - let updatedMessages = try await conversation.messages() - - let message = try await conversation.messages()[0] - let content: Reaction = try message.content() - XCTAssertEqual("U+1F603", content.content) - XCTAssertEqual(messageToReact.id, content.reference) - XCTAssertEqual(ReactionAction.added, content.action) - XCTAssertEqual(ReactionSchema.unicode, content.schema) - } - - func testCanDecodeEmptyForm() async throws { - let codec = ReactionCodec() - - // This is how clients send reactions now. - let canonicalEncoded = EncodedContent.with { - $0.type = ContentTypeReaction - $0.content = Data(""" - { - "action": "", - "content": "smile", - "reference": "", - "schema": "" - } - """.utf8) - } - - // Previously, some clients sent reactions like this. - // So we test here to make sure we can still decode them. - let legacyEncoded = EncodedContent.with { - $0.type = ContentTypeReaction - $0.parameters = [ - "action": "", - "reference": "", - "schema": "", - ] - $0.content = Data("smile".utf8) - } - - let fixtures = await fixtures() - - let canonical = try codec.decode(content: canonicalEncoded, client: fixtures.aliceClient) - let legacy = try codec.decode(content: legacyEncoded, client: fixtures.aliceClient) - - XCTAssertEqual(ReactionAction.unknown, canonical.action) - XCTAssertEqual(ReactionAction.unknown, legacy.action) - XCTAssertEqual("smile", canonical.content) - XCTAssertEqual("smile", legacy.content) - XCTAssertEqual("", canonical.reference) - XCTAssertEqual("", legacy.reference) - XCTAssertEqual(ReactionSchema.unknown, canonical.schema) - XCTAssertEqual(ReactionSchema.unknown, legacy.schema) - } + func testCanDecodeLegacyForm() async throws { + let codec = ReactionCodec() + + // This is how clients send reactions now. + let canonicalEncoded = EncodedContent.with { + $0.type = ContentTypeReaction + $0.content = Data( + """ + { + "action": "added", + "content": "smile", + "reference": "abc123", + "schema": "shortcode" + } + """.utf8) + } + + // Previously, some clients sent reactions like this. + // So we test here to make sure we can still decode them. + let legacyEncoded = EncodedContent.with { + $0.type = ContentTypeReaction + $0.parameters = [ + "action": "added", + "reference": "abc123", + "schema": "shortcode", + ] + $0.content = Data("smile".utf8) + } + + let fixtures = try await fixtures() + let canonical = try codec.decode( + content: canonicalEncoded, client: fixtures.alixClient) + let legacy = try codec.decode( + content: legacyEncoded, client: fixtures.alixClient) + + XCTAssertEqual(ReactionAction.added, canonical.action) + XCTAssertEqual(ReactionAction.added, legacy.action) + XCTAssertEqual("smile", canonical.content) + XCTAssertEqual("smile", legacy.content) + XCTAssertEqual("abc123", canonical.reference) + XCTAssertEqual("abc123", legacy.reference) + XCTAssertEqual(ReactionSchema.shortcode, canonical.schema) + XCTAssertEqual(ReactionSchema.shortcode, legacy.schema) + } + + func testCanUseReactionCodec() async throws { + let fixtures = try await fixtures() + let conversation = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) + + fixtures.alixClient.register(codec: ReactionCodec()) + + _ = try await conversation.send(text: "hey alix 2 bo") + + let messageToReact = try await conversation.messages()[0] + + let reaction = Reaction( + reference: messageToReact.id, + action: .added, + content: "U+1F603", + schema: .unicode + ) + + try await conversation.send( + content: reaction, + options: .init(contentType: ContentTypeReaction) + ) + + _ = try await conversation.messages() + + let message = try await conversation.messages()[0] + let content: Reaction = try message.content() + XCTAssertEqual("U+1F603", content.content) + XCTAssertEqual(messageToReact.id, content.reference) + XCTAssertEqual(ReactionAction.added, content.action) + XCTAssertEqual(ReactionSchema.unicode, content.schema) + } + + func testCanDecodeEmptyForm() async throws { + let codec = ReactionCodec() + + // This is how clients send reactions now. + let canonicalEncoded = EncodedContent.with { + $0.type = ContentTypeReaction + $0.content = Data( + """ + { + "action": "", + "content": "smile", + "reference": "", + "schema": "" + } + """.utf8) + } + + // Previously, some clients sent reactions like this. + // So we test here to make sure we can still decode them. + let legacyEncoded = EncodedContent.with { + $0.type = ContentTypeReaction + $0.parameters = [ + "action": "", + "reference": "", + "schema": "", + ] + $0.content = Data("smile".utf8) + } + + let fixtures = try await fixtures() + + let canonical = try codec.decode( + content: canonicalEncoded, client: fixtures.alixClient) + let legacy = try codec.decode( + content: legacyEncoded, client: fixtures.alixClient) + + XCTAssertEqual(ReactionAction.unknown, canonical.action) + XCTAssertEqual(ReactionAction.unknown, legacy.action) + XCTAssertEqual("smile", canonical.content) + XCTAssertEqual("smile", legacy.content) + XCTAssertEqual("", canonical.reference) + XCTAssertEqual("", legacy.reference) + XCTAssertEqual(ReactionSchema.unknown, canonical.schema) + XCTAssertEqual(ReactionSchema.unknown, legacy.schema) + } } diff --git a/Tests/XMTPTests/ReadReceiptTests.swift b/Tests/XMTPTests/ReadReceiptTests.swift index 9675cc36..431a4a69 100644 --- a/Tests/XMTPTests/ReadReceiptTests.swift +++ b/Tests/XMTPTests/ReadReceiptTests.swift @@ -1,24 +1,18 @@ -// -// ReadReceiptTests.swift -// -// -// Created by Naomi Plasterer on 8/2/23. -// - import Foundation - import XCTest + @testable import XMTPiOS @available(iOS 15, *) class ReadReceiptTests: XCTestCase { func testCanUseReadReceiptCodec() async throws { - let fixtures = await fixtures() - fixtures.aliceClient.register(codec: ReadReceiptCodec()) + let fixtures = try await fixtures() + fixtures.alixClient.register(codec: ReadReceiptCodec()) - let conversation = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) + let conversation = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) - try await conversation.send(text: "hey alice 2 bob") + _ = try await conversation.send(text: "hey alix 2 bo") let read = ReadReceipt() @@ -27,7 +21,7 @@ class ReadReceiptTests: XCTestCase { options: .init(contentType: ContentTypeReadReceipt) ) - let updatedMessages = try await conversation.messages() + _ = try await conversation.messages() let message = try await conversation.messages()[0] let contentType: String = message.encodedContent.type.typeID diff --git a/Tests/XMTPTests/RemoteAttachmentTest.swift b/Tests/XMTPTests/RemoteAttachmentTest.swift index a49882d3..b9063250 100644 --- a/Tests/XMTPTests/RemoteAttachmentTest.swift +++ b/Tests/XMTPTests/RemoteAttachmentTest.swift @@ -1,18 +1,16 @@ -// -// RemoteAttachmentTests.swift -// -// -// Created by Pat on 2/14/23. -// import Foundation - import XCTest + @testable import XMTPiOS // Fakes HTTPS urls struct TestFetcher: RemoteContentFetcher { func fetch(_ url: String) async throws -> Data { - guard let localURL = URL(string: url.replacingOccurrences(of: "https://", with: "file://")) else { + guard + let localURL = URL( + string: url.replacingOccurrences( + of: "https://", with: "file://")) + else { throw RemoteAttachmentError.invalidURL } @@ -27,55 +25,70 @@ class RemoteAttachmentTests: XCTestCase { override func setUp() async throws { // swiftlint:disable force_try - iconData = Data(base64Encoded: Data("iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAACymlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj43MjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+NzI8L3RpZmY6WFJlc29sdXRpb24+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj40NjA8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjQ2MDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgr0TTmKAAAC5ElEQVQ4EW2TWWxMYRTHf3e2zjClVa0trVoqFRk1VKmIWhJ0JmkNETvvEtIHLwixxoM1xIOIiAjzxhBCQ9ESlRJNJEj7gJraJ63SdDrbvc53Z6xx7r253/lyzvnO/3/+n7a69KTBnyae1anJZ0nviq9pkIzppKLK+TMYbH+74Bhsobslzmv6yJQgJUHFuMiryCL+Tf8r5XcBqWxzWWhv+c6cDSPYsm4ehWPy5XSNd28j3Aw+49apMOO92aT6pRN5lf0qoJI7nvay4/JcFi+ZTiKepLPjC4ahM3VGCZVVk6iqaWWv/w5F3gEkFRyzgPxV221y8s5L6eSbocdUB25QhFUeBE6C0MWF1K6aReqqzs6aBkorBhHv0bEpwr4K5tlrhrM4MJ36K084HXhEfcjH/WvtJBM685dO5MymRyacmpWVNKx7Sdv5LrLL7FhU64ow//rJxGMJTix5QP4CF/P9Xjbv81F3wM8CWQ/1uDixqpn+aJzqtR5eSY6alMUQCIrXwuJ8PrzrokfaDTf0cnhbiPxhOQwbkcvBrZd5e/07SYl83xmhaGyBgm/az0ll3DQxulCc5fzFr7nuIs5Dotjtsm8emo61KZEobXS+iTCzaiJuGUxJTQ51u2t5H46QTKao21NL9+cgG6cNl04LCJ6+xxDsGCkDqyfPt2vgJyvdWg+LlgvWMhvNFzpwF2sEjzdzO/iCyurx+FaU45k2hicP2zgSaGLUFBlln4FNiSKnwkHT+Y/UL31sTkLXDdHCdSbIKVHp90PBWRbuH0dPJMrdo2EKSp3osQwE1b+SZ4nXzYFAI1pIw7esgv5+b0ZIBucONXJ2+3NG4mTk1AFyJ4QlxbzkWj1D/bsUg7oIfkihg0vH2nkVfoM7105untsk7UVrmL7WGLnlWSR6M3dBESem/XsbHYMsdLXERBtRU4UqaFz2QJyjbRgJaTuTqPaV/Z5V2jflObjMQbnLKW2mcSaErP8lq5QfTHkZ9teKBsUAAAAASUVORK5CYII=".utf8))! + iconData = Data( + base64Encoded: Data( + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAADHbxzxAAAACXBIWXMAAAsTAAALEwEAmpwYAAACymlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj43MjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4KICAgICAgICAgPHRpZmY6WFJlc29sdXRpb24+NzI8L3RpZmY6WFJlc29sdXRpb24+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj40NjA8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjQ2MDwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgr0TTmKAAAC5ElEQVQ4EW2TWWxMYRTHf3e2zjClVa0trVoqFRk1VKmIWhJ0JmkNETvvEtIHLwixxoM1xIOIiAjzxhBCQ9ESlRJNJEj7gJraJ63SdDrbvc53Z6xx7r253/lyzvnO/3/+n7a69KTBnyae1anJZ0nviq9pkIzppKLK+TMYbH+74Bhsobslzmv6yJQgJUHFuMiryCL+Tf8r5XcBqWxzWWhv+c6cDSPYsm4ehWPy5XSNd28j3Aw+49apMOO92aT6pRN5lf0qoJI7nvay4/JcFi+ZTiKepLPjC4ahM3VGCZVVk6iqaWWv/w5F3gEkFRyzgPxV221y8s5L6eSbocdUB25QhFUeBE6C0MWF1K6aReqqzs6aBkorBhHv0bEpwr4K5tlrhrM4MJ36K084HXhEfcjH/WvtJBM685dO5MymRyacmpWVNKx7Sdv5LrLL7FhU64ow//rJxGMJTix5QP4CF/P9Xjbv81F3wM8CWQ/1uDixqpn+aJzqtR5eSY6alMUQCIrXwuJ8PrzrokfaDTf0cnhbiPxhOQwbkcvBrZd5e/07SYl83xmhaGyBgm/az0ll3DQxulCc5fzFr7nuIs5Dotjtsm8emo61KZEobXS+iTCzaiJuGUxJTQ51u2t5H46QTKao21NL9+cgG6cNl04LCJ6+xxDsGCkDqyfPt2vgJyvdWg+LlgvWMhvNFzpwF2sEjzdzO/iCyurx+FaU45k2hicP2zgSaGLUFBlln4FNiSKnwkHT+Y/UL31sTkLXDdHCdSbIKVHp90PBWRbuH0dPJMrdo2EKSp3osQwE1b+SZ4nXzYFAI1pIw7esgv5+b0ZIBucONXJ2+3NG4mTk1AFyJ4QlxbzkWj1D/bsUg7oIfkihg0vH2nkVfoM7105untsk7UVrmL7WGLnlWSR6M3dBESem/XsbHYMsdLXERBtRU4UqaFz2QJyjbRgJaTuTqPaV/Z5V2jflObjMQbnLKW2mcSaErP8lq5QfTHkZ9teKBsUAAAAASUVORK5CYII=" + .utf8))! } func testBasic() async throws { - let fixtures = await fixtures() - - fixtures.aliceClient.register(codec: AttachmentCodec()) - fixtures.aliceClient.register(codec: RemoteAttachmentCodec()) - - let conversation = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) - let enecryptedEncodedContent = try RemoteAttachment.encodeEncrypted(content: "Hello", codec: TextCodec(), with: fixtures.aliceClient) - var remoteAttachmentContent = try RemoteAttachment(url: "https://example.com", encryptedEncodedContent: enecryptedEncodedContent) + let fixtures = try await fixtures() + + fixtures.alixClient.register(codec: AttachmentCodec()) + fixtures.alixClient.register(codec: RemoteAttachmentCodec()) + + let conversation = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) + let enecryptedEncodedContent = try RemoteAttachment.encodeEncrypted( + content: "Hello", codec: TextCodec(), with: fixtures.alixClient) + var remoteAttachmentContent = try RemoteAttachment( + url: "https://example.com", + encryptedEncodedContent: enecryptedEncodedContent) remoteAttachmentContent.filename = "hello.txt" remoteAttachmentContent.contentLength = 5 - _ = try await conversation.send(content: remoteAttachmentContent, options: .init(contentType: ContentTypeRemoteAttachment)) + _ = try await conversation.send( + content: remoteAttachmentContent, + options: .init(contentType: ContentTypeRemoteAttachment)) } func testCanUseAttachmentCodec() async throws { - let fixtures = await fixtures() - guard case let .v2(conversation) = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) else { - XCTFail("no v2 convo") - return - } + let fixtures = try await fixtures() + let conversation = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) - fixtures.aliceClient.register(codec: AttachmentCodec()) - fixtures.aliceClient.register(codec: RemoteAttachmentCodec()) + fixtures.alixClient.register(codec: AttachmentCodec()) + fixtures.alixClient.register(codec: RemoteAttachmentCodec()) let encryptedEncodedContent = try RemoteAttachment.encodeEncrypted( - content: Attachment(filename: "icon.png", mimeType: "image/png", data: iconData), + content: Attachment( + filename: "icon.png", mimeType: "image/png", data: iconData), codec: AttachmentCodec(), - with: fixtures.aliceClient + with: fixtures.alixClient ) - let tempFileURL = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let tempFileURL = URL.temporaryDirectory.appendingPathComponent( + UUID().uuidString) try encryptedEncodedContent.payload.write(to: tempFileURL) // We only allow https:// urls for remote attachments, but it didn't seem worthwhile to spin up a local web server // for this, so we use the TestFetcher to swap the protocols - let fakeHTTPSFileURL = URL(string: tempFileURL.absoluteString.replacingOccurrences(of: "file://", with: "https://"))! - var content = try RemoteAttachment(url: fakeHTTPSFileURL.absoluteString, encryptedEncodedContent: encryptedEncodedContent) + let fakeHTTPSFileURL = URL( + string: tempFileURL.absoluteString.replacingOccurrences( + of: "file://", with: "https://"))! + var content = try RemoteAttachment( + url: fakeHTTPSFileURL.absoluteString, + encryptedEncodedContent: encryptedEncodedContent) content.filename = "icon.png" content.contentLength = 123 content.fetcher = TestFetcher() - try await conversation.send(content: content, options: .init(contentType: ContentTypeRemoteAttachment)) + try await conversation.send( + content: content, + options: .init(contentType: ContentTypeRemoteAttachment)) let messages = try await conversation.messages() - XCTAssertEqual(1, messages.count) + XCTAssertEqual(2, messages.count) let receivedMessage = messages[0] var remoteAttachment: RemoteAttachment = try receivedMessage.content() @@ -85,8 +98,10 @@ class RemoteAttachmentTests: XCTestCase { remoteAttachment.fetcher = TestFetcher() - let encodedContent: EncodedContent = try await remoteAttachment.content() - let attachment: Attachment = try encodedContent.decoded(with: fixtures.aliceClient) + let encodedContent: EncodedContent = + try await remoteAttachment.content() + let attachment: Attachment = try encodedContent.decoded( + with: fixtures.alixClient) XCTAssertEqual("icon.png", attachment.filename) XCTAssertEqual("image/png", attachment.mimeType) @@ -95,25 +110,29 @@ class RemoteAttachmentTests: XCTestCase { } func testCannotUseNonHTTPSUrl() async throws { - let fixtures = await fixtures() - guard case let .v2(conversation) = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) else { - XCTFail("no v2 convo") - return - } + let fixtures = try await fixtures() + _ = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) - fixtures.aliceClient.register(codec: AttachmentCodec()) - fixtures.aliceClient.register(codec: RemoteAttachmentCodec()) + fixtures.alixClient.register(codec: AttachmentCodec()) + fixtures.alixClient.register(codec: RemoteAttachmentCodec()) let encryptedEncodedContent = try RemoteAttachment.encodeEncrypted( - content: Attachment(filename: "icon.png", mimeType: "image/png", data: iconData), + content: Attachment( + filename: "icon.png", mimeType: "image/png", data: iconData), codec: AttachmentCodec(), - with: fixtures.aliceClient + with: fixtures.alixClient ) - let tempFileURL = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let tempFileURL = URL.temporaryDirectory.appendingPathComponent( + UUID().uuidString) try encryptedEncodedContent.payload.write(to: tempFileURL) - XCTAssertThrowsError(try RemoteAttachment(url: tempFileURL.absoluteString, encryptedEncodedContent: encryptedEncodedContent)) { error in + XCTAssertThrowsError( + try RemoteAttachment( + url: tempFileURL.absoluteString, + encryptedEncodedContent: encryptedEncodedContent) + ) { error in switch error as! RemoteAttachmentError { case let .invalidScheme(message): XCTAssertEqual(message, "scheme must be https") @@ -124,24 +143,28 @@ class RemoteAttachmentTests: XCTestCase { } func testVerifiesContentDigest() async throws { - let fixtures = await fixtures() - guard case let .v2(_) = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) else { - XCTFail("no v2 convo") - return - } + let fixtures = try await fixtures() + _ = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) let encryptedEncodedContent = try RemoteAttachment.encodeEncrypted( - content: Attachment(filename: "icon.png", mimeType: "image/png", data: iconData), + content: Attachment( + filename: "icon.png", mimeType: "image/png", data: iconData), codec: AttachmentCodec(), - with: fixtures.aliceClient + with: fixtures.alixClient ) - let tempFileURL = URL.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let tempFileURL = URL.temporaryDirectory.appendingPathComponent( + UUID().uuidString) try encryptedEncodedContent.payload.write(to: tempFileURL) - let fakeHTTPSFileURL = URL(string: tempFileURL.absoluteString.replacingOccurrences(of: "file://", with: "https://"))! - var remoteAttachment = try RemoteAttachment(url: fakeHTTPSFileURL.absoluteString, encryptedEncodedContent: encryptedEncodedContent) + let fakeHTTPSFileURL = URL( + string: tempFileURL.absoluteString.replacingOccurrences( + of: "file://", with: "https://"))! + var remoteAttachment = try RemoteAttachment( + url: fakeHTTPSFileURL.absoluteString, + encryptedEncodedContent: encryptedEncodedContent) remoteAttachment.fetcher = TestFetcher() - let expect = expectation(description: "raised error") + let expect = XCTestExpectation(description: "raised error") // Tamper with content try Data([1, 2, 3, 4, 5]).write(to: tempFileURL) @@ -149,12 +172,16 @@ class RemoteAttachmentTests: XCTestCase { do { _ = try await remoteAttachment.content() } catch { - if let error = error as? RemoteAttachmentError, case let .invalidDigest(message) = error { + if let error = error as? RemoteAttachmentError, + case let .invalidDigest(message) = error + { XCTAssert(message.hasPrefix("content digest does not match")) expect.fulfill() } } + + - wait(for: [expect], timeout: 3) + await fulfillment(of: [expect], timeout: 3) } } diff --git a/Tests/XMTPTests/ReplyTests.swift b/Tests/XMTPTests/ReplyTests.swift index 3e4433c5..5fbb2920 100644 --- a/Tests/XMTPTests/ReplyTests.swift +++ b/Tests/XMTPTests/ReplyTests.swift @@ -1,23 +1,18 @@ -// -// ReplyTests.swift -// -// -// Created by Naomi Plasterer on 7/26/23. -// import Foundation - import XCTest + @testable import XMTPiOS @available(iOS 15, *) class ReplyTests: XCTestCase { func testCanUseReplyCodec() async throws { - let fixtures = await fixtures() - let conversation = try await fixtures.aliceClient.conversations.newConversation(with: fixtures.bobClient.address) + let fixtures = try await fixtures() + let conversation = try await fixtures.alixClient.conversations + .newConversation(with: fixtures.boClient.address) - fixtures.aliceClient.register(codec: ReplyCodec()) + fixtures.alixClient.register(codec: ReplyCodec()) - try await conversation.send(text: "hey alice 2 bob") + _ = try await conversation.send(text: "hey alix 2 bo") let messageToReply = try await conversation.messages()[0] @@ -32,7 +27,7 @@ class ReplyTests: XCTestCase { options: .init(contentType: ContentTypeReply) ) - let updatedMessages = try await conversation.messages() + _ = try await conversation.messages() let message = try await conversation.messages()[0] let content: Reply = try message.content() diff --git a/Tests/XMTPTests/SignatureTests.swift b/Tests/XMTPTests/SignatureTests.swift index b9cd7bde..9fa21975 100644 --- a/Tests/XMTPTests/SignatureTests.swift +++ b/Tests/XMTPTests/SignatureTests.swift @@ -1,12 +1,6 @@ -// -// SignatureTests.swift -// -// -// Created by Pat Nakajima on 11/27/22. -// - import CryptoKit import XCTest + @testable import XMTPiOS class SignatureTests: XCTestCase { @@ -14,15 +8,20 @@ class SignatureTests: XCTestCase { let digest = SHA256.hash(data: Data("Hello world".utf8)) let signingKey = try PrivateKey.generate() let signature = try await signingKey.sign(Data(digest)) - XCTAssert(try signature.verify(signedBy: signingKey.publicKey, digest: Data("Hello world".utf8))) + XCTAssert( + try signature.verify( + signedBy: signingKey.publicKey, digest: Data("Hello world".utf8) + )) } - - func testConsentProofText() { - let timestamp = UInt64(1581663600000) - let exampleAddress = "0x1234567890abcdef"; - let text = Signature.consentProofText(peerAddress: exampleAddress, timestamp: timestamp) - let expected = "XMTP : Grant inbox consent to sender\n\nCurrent Time: Fri, 14 Feb 2020 07:00:00 GMT\nFrom Address: 0x1234567890abcdef\n\nFor more info: https://xmtp.org/signatures/" - XCTAssertEqual(text, expected) - } + func testConsentProofText() { + let timestamp = UInt64(1_581_663_600_000) + let exampleAddress = "0x1234567890abcdef" + let text = Signature.consentProofText( + peerAddress: exampleAddress, timestamp: timestamp) + let expected = + "XMTP : Grant inbox consent to sender\n\nCurrent Time: Fri, 14 Feb 2020 07:00:00 GMT\nFrom Address: 0x1234567890abcdef\n\nFor more info: https://xmtp.org/signatures/" + + XCTAssertEqual(text, expected) + } } diff --git a/Tests/XMTPTests/V3ClientTests.swift b/Tests/XMTPTests/V3ClientTests.swift deleted file mode 100644 index fe6f8085..00000000 --- a/Tests/XMTPTests/V3ClientTests.swift +++ /dev/null @@ -1,470 +0,0 @@ -// -// V3ClientTests.swift -// -// -// Created by Naomi Plasterer on 9/19/24. -// - -import LibXMTP -import XCTest -import XMTPTestHelpers - -@testable import XMTPiOS - -@available(iOS 16, *) -class V3ClientTests: XCTestCase { - // Use these fixtures to talk to the local node - struct LocalFixtures { - var alixV2: PrivateKey! - var boV3: PrivateKey! - var caroV2V3: PrivateKey! - var alixV2Client: Client! - var boV3Client: Client! - var caroV2V3Client: Client! - } - - func localFixtures() async throws -> LocalFixtures { - let key = try Crypto.secureRandomBytes(count: 32) - let alixV2 = try PrivateKey.generate() - let alixV2Client = try await Client.create( - account: alixV2, - options: .init( - api: .init(env: .local, isSecure: false) - ) - ) - let boV3 = try PrivateKey.generate() - let boV3Client = try await Client.createV3( - account: boV3, - options: .init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) - ) - let caroV2V3 = try PrivateKey.generate() - let caroV2V3Client = try await Client.create( - account: caroV2V3, - options: .init( - api: .init(env: .local, isSecure: false), - enableV3: true, - encryptionKey: key - ) - ) - - return .init( - alixV2: alixV2, - boV3: boV3, - caroV2V3: caroV2V3, - alixV2Client: alixV2Client, - boV3Client: boV3Client, - caroV2V3Client: caroV2V3Client - ) - } - - func testsCanCreateGroup() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.address - ]) - let members = try await group.members.map(\.inboxId).sorted() - XCTAssertEqual( - [fixtures.caroV2V3Client.inboxID, fixtures.boV3Client.inboxID] - .sorted(), members) - - await assertThrowsAsyncError( - try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.alixV2.address - ]) - ) - } - - func testCanCreateDm() async throws { - let fixtures = try await localFixtures() - - let dm = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.walletAddress) - let members = try await dm.members - XCTAssertEqual(members.count, 2) - - let sameDm = try await fixtures.boV3Client.findDm( - address: fixtures.caroV2V3.walletAddress) - XCTAssertEqual(sameDm?.id, dm.id) - - try await fixtures.caroV2V3Client.conversations.sync() - let caroDm = try await fixtures.caroV2V3Client.findDm( - address: fixtures.boV3Client.address) - XCTAssertEqual(caroDm?.id, dm.id) - - await assertThrowsAsyncError( - try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.alixV2.walletAddress) - ) - } - - func testCanFindConversationByTopic() async throws { - let fixtures = try await localFixtures() - - let group = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.walletAddress - ]) - let dm = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.walletAddress) - - let sameDm = try fixtures.boV3Client.findConversationByTopic( - topic: dm.topic) - let sameGroup = try fixtures.boV3Client.findConversationByTopic( - topic: group.topic) - - XCTAssertEqual(group.id, try sameGroup?.id) - XCTAssertEqual(dm.id, try sameDm?.id) - } - - func testCanListConversations() async throws { - let fixtures = try await localFixtures() - - let dm = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.walletAddress) - let group = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.walletAddress - ]) - - let convoCount = try await fixtures.boV3Client.conversations - .listConversations().count - let dmCount = try await fixtures.boV3Client.conversations.dms().count - let groupCount = try await fixtures.boV3Client.conversations.groups() - .count - XCTAssertEqual(convoCount, 2) - XCTAssertEqual(dmCount, 1) - XCTAssertEqual(groupCount, 1) - - try await fixtures.caroV2V3Client.conversations.sync() - let convoCount2 = try await fixtures.caroV2V3Client.conversations.list( - includeGroups: true - ).count - let groupCount2 = try await fixtures.caroV2V3Client.conversations - .groups().count - XCTAssertEqual(convoCount2, 1) - XCTAssertEqual(groupCount2, 1) - } - - func testCanListConversationsFiltered() async throws { - let fixtures = try await localFixtures() - - let dm = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.walletAddress) - let group = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.walletAddress - ]) - - let convoCount = try await fixtures.boV3Client.conversations - .listConversations().count - let convoCountConsent = try await fixtures.boV3Client.conversations - .listConversations(consentState: .allowed).count - - XCTAssertEqual(convoCount, 2) - XCTAssertEqual(convoCountConsent, 2) - - try await group.updateConsentState(state: .denied) - - let convoCountAllowed = try await fixtures.boV3Client.conversations - .listConversations(consentState: .allowed).count - let convoCountDenied = try await fixtures.boV3Client.conversations - .listConversations(consentState: .denied).count - - XCTAssertEqual(convoCountAllowed, 1) - XCTAssertEqual(convoCountDenied, 1) - } - - func testCanListConversationsOrder() async throws { - let fixtures = try await localFixtures() - - let dm = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.walletAddress) - let group1 = try await fixtures.boV3Client.conversations.newGroup( - with: [fixtures.caroV2V3.walletAddress]) - let group2 = try await fixtures.boV3Client.conversations.newGroup( - with: [fixtures.caroV2V3.walletAddress]) - - _ = try await dm.send(content: "Howdy") - _ = try await group2.send(content: "Howdy") - _ = try await fixtures.boV3Client.conversations.syncAllConversations() - - let conversations = try await fixtures.boV3Client.conversations - .listConversations() - let conversationsOrdered = try await fixtures.boV3Client.conversations - .listConversations(order: .lastMessage) - - XCTAssertEqual(conversations.count, 3) - XCTAssertEqual(conversationsOrdered.count, 3) - - XCTAssertEqual( - try conversations.map { try $0.id }, [dm.id, group1.id, group2.id]) - XCTAssertEqual( - try conversationsOrdered.map { try $0.id }, - [group2.id, dm.id, group1.id]) - } - - func testsCanSendMessages() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.address - ]) - try await group.send(content: "howdy") - let messageId = try await group.send(content: "gm") - try await group.sync() - - let groupMessages = try await group.messages() - XCTAssertEqual(groupMessages.first?.body, "gm") - XCTAssertEqual(groupMessages.first?.id, messageId) - XCTAssertEqual(groupMessages.first?.deliveryStatus, .published) - XCTAssertEqual(groupMessages.count, 3) - - try await fixtures.caroV2V3Client.conversations.sync() - let sameGroup = try await fixtures.caroV2V3Client.conversations.groups() - .last - try await sameGroup?.sync() - - let sameGroupMessages = try await sameGroup?.messages() - XCTAssertEqual(sameGroupMessages?.count, 2) - XCTAssertEqual(sameGroupMessages?.first?.body, "gm") - } - - func testsCanSendMessagesToDm() async throws { - let fixtures = try await localFixtures() - let dm = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.address) - try await dm.send(content: "howdy") - let messageId = try await dm.send(content: "gm") - try await dm.sync() - - let dmMessages = try await dm.messages() - XCTAssertEqual(dmMessages.first?.body, "gm") - XCTAssertEqual(dmMessages.first?.id, messageId) - XCTAssertEqual(dmMessages.first?.deliveryStatus, .published) - XCTAssertEqual(dmMessages.count, 3) - - try await fixtures.caroV2V3Client.conversations.sync() - let sameDm = try await fixtures.caroV2V3Client.findDm( - address: fixtures.boV3Client.address) - try await sameDm?.sync() - - let sameDmMessages = try await sameDm?.messages() - XCTAssertEqual(sameDmMessages?.count, 2) - XCTAssertEqual(sameDmMessages?.first?.body, "gm") - } - - func testGroupConsent() async throws { - let fixtures = try await localFixtures() - let group = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.address - ]) - let isAllowed = try await fixtures.boV3Client.contacts.isGroupAllowed( - groupId: group.id) - XCTAssert(isAllowed) - XCTAssertEqual(try group.consentState(), .allowed) - - try await fixtures.boV3Client.contacts.denyGroups(groupIds: [group.id]) - let isDenied = try await fixtures.boV3Client.contacts.isGroupDenied( - groupId: group.id) - XCTAssert(isDenied) - XCTAssertEqual(try group.consentState(), .denied) - - try await group.updateConsentState(state: .allowed) - let isAllowed2 = try await fixtures.boV3Client.contacts.isGroupAllowed( - groupId: group.id) - XCTAssert(isAllowed2) - XCTAssertEqual(try group.consentState(), .allowed) - } - - func testCanAllowAndDenyInboxId() async throws { - let fixtures = try await localFixtures() - let boGroup = try await fixtures.boV3Client.conversations.newGroup( - with: [fixtures.caroV2V3.address]) - var isInboxAllowed = try await fixtures.boV3Client.contacts - .isInboxAllowed(inboxId: fixtures.caroV2V3.address) - var isInboxDenied = try await fixtures.boV3Client.contacts - .isInboxDenied(inboxId: fixtures.caroV2V3.address) - XCTAssert(!isInboxAllowed) - XCTAssert(!isInboxDenied) - - try await fixtures.boV3Client.contacts.allowInboxes(inboxIds: [ - fixtures.caroV2V3Client.inboxID - ]) - var caroMember = try await boGroup.members.first(where: { member in - member.inboxId == fixtures.caroV2V3Client.inboxID - }) - XCTAssertEqual(caroMember?.consentState, .allowed) - - isInboxAllowed = try await fixtures.boV3Client.contacts.isInboxAllowed( - inboxId: fixtures.caroV2V3Client.inboxID) - XCTAssert(isInboxAllowed) - isInboxDenied = try await fixtures.boV3Client.contacts.isInboxDenied( - inboxId: fixtures.caroV2V3Client.inboxID) - XCTAssert(!isInboxDenied) - var isAddressAllowed = try await fixtures.boV3Client.contacts.isAllowed( - fixtures.caroV2V3Client.address) - XCTAssert(isAddressAllowed) - var isAddressDenied = try await fixtures.boV3Client.contacts.isDenied( - fixtures.caroV2V3Client.address) - XCTAssert(!isAddressDenied) - - try await fixtures.boV3Client.contacts.denyInboxes(inboxIds: [ - fixtures.caroV2V3Client.inboxID - ]) - caroMember = try await boGroup.members.first(where: { member in - member.inboxId == fixtures.caroV2V3Client.inboxID - }) - XCTAssertEqual(caroMember?.consentState, .denied) - - isInboxAllowed = try await fixtures.boV3Client.contacts.isInboxAllowed( - inboxId: fixtures.caroV2V3Client.inboxID) - isInboxDenied = try await fixtures.boV3Client.contacts.isInboxDenied( - inboxId: fixtures.caroV2V3Client.inboxID) - XCTAssert(!isInboxAllowed) - XCTAssert(isInboxDenied) - - try await fixtures.boV3Client.contacts.allow(addresses: [ - fixtures.alixV2.address - ]) - isAddressAllowed = try await fixtures.boV3Client.contacts.isAllowed( - fixtures.alixV2.address) - isAddressDenied = try await fixtures.boV3Client.contacts.isDenied( - fixtures.alixV2.address) - XCTAssert(isAddressAllowed) - XCTAssert(!isAddressDenied) - } - - func testCanStreamAllMessagesFromV3Users() async throws { - let fixtures = try await localFixtures() - - let expectation1 = XCTestExpectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - let convo = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.address) - let group = try await fixtures.caroV2V3Client.conversations.newGroup( - with: [fixtures.boV3.address]) - try await fixtures.boV3Client.conversations.sync() - Task(priority: .userInitiated) { - for try await _ in await fixtures.boV3Client.conversations - .streamAllConversationMessages() - { - expectation1.fulfill() - } - } - - _ = try await group.send(content: "hi") - _ = try await convo.send(content: "hi") - - await fulfillment(of: [expectation1], timeout: 3) - } - - func testCanStreamAllDecryptedMessagesFromV3Users() async throws { - let fixtures = try await localFixtures() - - let expectation1 = XCTestExpectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - let convo = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.address) - let group = try await fixtures.caroV2V3Client.conversations.newGroup( - with: [fixtures.boV3.address]) - try await fixtures.boV3Client.conversations.sync() - Task(priority: .userInitiated) { - for try await _ in await fixtures.boV3Client.conversations - .streamAllDecryptedConversationMessages() - { - expectation1.fulfill() - } - } - - _ = try await group.send(content: "hi") - _ = try await convo.send(content: "hi") - - await fulfillment(of: [expectation1], timeout: 3) - } - - func testCanStreamGroupsAndConversationsFromV3Users() async throws { - let fixtures = try await localFixtures() - - let expectation1 = XCTestExpectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - - Task(priority: .userInitiated) { - for try await _ in await fixtures.boV3Client.conversations - .streamConversations() - { - expectation1.fulfill() - } - } - - _ = try await fixtures.caroV2V3Client.conversations.newGroup(with: [ - fixtures.boV3.address - ]) - _ = try await fixtures.boV3Client.conversations.findOrCreateDm( - with: fixtures.caroV2V3.address) - - await fulfillment(of: [expectation1], timeout: 3) - } - - func testCanStreamAllMessagesFromV2andV3Users() async throws { - let fixtures = try await localFixtures() - - let expectation1 = XCTestExpectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - let convo = try await fixtures.alixV2Client.conversations - .newConversation(with: fixtures.caroV2V3.address) - let group = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.address - ]) - try await fixtures.caroV2V3Client.conversations.sync() - Task(priority: .userInitiated) { - for try await _ in await fixtures.caroV2V3Client.conversations - .streamAllMessages(includeGroups: true) - { - expectation1.fulfill() - } - } - - _ = try await group.send(content: "hi") - _ = try await convo.send(content: "hi") - - await fulfillment(of: [expectation1], timeout: 3) - } - - func testCanStreamGroupsAndConversationsFromV2andV3Users() async throws { - let fixtures = try await localFixtures() - - let expectation1 = XCTestExpectation(description: "got a conversation") - expectation1.expectedFulfillmentCount = 2 - - Task(priority: .userInitiated) { - for try await _ in await fixtures.caroV2V3Client.conversations - .streamAll() - { - expectation1.fulfill() - } - } - - _ = try await fixtures.boV3Client.conversations.newGroup(with: [ - fixtures.caroV2V3.address - ]) - _ = try await fixtures.alixV2Client.conversations.newConversation( - with: fixtures.caroV2V3.address) - - await fulfillment(of: [expectation1], timeout: 3) - } - - func createDms(client: Client, peers: [Client], numMessages: Int) - async throws -> [Dm] - { - var dms: [Dm] = [] - for peer in peers { - let dm = try await peer.conversations.findOrCreateDm( - with: client.address) - dms.append(dm) - for i in 0.. - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.usernotifications.service - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).NotificationService - - - diff --git a/XMTPiOSExample/NotificationService/NotificationService.entitlements b/XMTPiOSExample/NotificationService/NotificationService.entitlements deleted file mode 100644 index a1df3603..00000000 --- a/XMTPiOSExample/NotificationService/NotificationService.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - aps-environment - development - keychain-access-groups - - $(AppIdentifierPrefix)com.xmtp.XMTPiOSExample - - - diff --git a/XMTPiOSExample/NotificationService/NotificationService.swift b/XMTPiOSExample/NotificationService/NotificationService.swift deleted file mode 100644 index 32c4cc1d..00000000 --- a/XMTPiOSExample/NotificationService/NotificationService.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// NotificationService.swift -// NotificationService -// -// Created by Pat Nakajima on 1/20/23. -// - -import UserNotifications -import XMTPiOS - -class NotificationService: UNNotificationServiceExtension { - var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNMutableNotificationContent? - - override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - - do { - guard let encryptedMessage = request.content.userInfo["encryptedMessage"] as? String, - let topic = request.content.userInfo["topic"] as? String, - let encryptedMessageData = Data(base64Encoded: Data(encryptedMessage.utf8)) - else { - print("Did not get correct message data from push") - return - } - - let persistence = Persistence() - - guard let keysData = persistence.loadKeys(), - let keys = try? PrivateKeyBundle(serializedData: keysData), - let conversationContainer = try persistence.load(conversationTopic: topic) - else { - print("No keys or conversation persisted") - return - } - - Task { - let client = try await Client.from(bundle: keys) - let conversation = conversationContainer.decode(with: client) - - let envelope = XMTPiOS.Envelope.with { envelope in - envelope.message = encryptedMessageData - envelope.contentTopic = topic - } - - if let bestAttemptContent = bestAttemptContent { - let decodedMessage = try conversation.decode(envelope) - - bestAttemptContent.body = (try? decodedMessage.content()) ?? "no content" - - contentHandler(bestAttemptContent) - } - } - } catch { - print("Error receiving notification: \(error)") - } - } - - override func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { - contentHandler(bestAttemptContent) - } - } -} diff --git a/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.pbxproj b/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.pbxproj index bbfa15d8..325748c3 100644 --- a/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.pbxproj +++ b/XMTPiOSExample/XMTPiOSExample.xcodeproj/project.pbxproj @@ -15,7 +15,6 @@ A6281995292DC825004B9117 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6281994292DC825004B9117 /* ContentView.swift */; }; A6281997292DC826004B9117 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6281996292DC826004B9117 /* Assets.xcassets */; }; A628199B292DC826004B9117 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A628199A292DC826004B9117 /* Preview Assets.xcassets */; }; - A6494F002B6C2DF700D9FFB9 /* XMTPiOS in Frameworks */ = {isa = PBXBuildFile; productRef = A6494EFF2B6C2DF700D9FFB9 /* XMTPiOS */; }; A6557A312941166E00CC4C7B /* MessageCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6557A302941166E00CC4C7B /* MessageCellView.swift */; }; A6557A3329411F4F00CC4C7B /* NewConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6557A3229411F4F00CC4C7B /* NewConversationView.swift */; }; A65F0704297B5D4E00C3C76E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F0703297B5D4E00C3C76E /* Persistence.swift */; }; @@ -32,10 +31,6 @@ A68807152B6C53E0004340BD /* GroupDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A68807142B6C53E0004340BD /* GroupDetailView.swift */; }; A69F33CA292DD557005A5556 /* LoggedInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69F33C9292DD557005A5556 /* LoggedInView.swift */; }; A69F33CC292DD568005A5556 /* QRCodeSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69F33CB292DD568005A5556 /* QRCodeSheetView.swift */; }; - A6AE5187297B61AE006FDD0F /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A6AE5180297B61AE006FDD0F /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - A6AE518E297B6210006FDD0F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AE518C297B6210006FDD0F /* NotificationService.swift */; }; - A6AE5192297B6270006FDD0F /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F0703297B5D4E00C3C76E /* Persistence.swift */; }; - A6AE5194297B62C8006FDD0F /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = A6AE5193297B62C8006FDD0F /* KeychainAccess */; }; A6C0F37B2AC1E321008C6AA7 /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = A6C0F37A2AC1E321008C6AA7 /* Starscream */; }; A6C0F37E2AC1E34F008C6AA7 /* WalletConnect in Frameworks */ = {isa = PBXBuildFile; productRef = A6C0F37D2AC1E34F008C6AA7 /* WalletConnect */; }; A6C0F3802AC1E34F008C6AA7 /* WalletConnectModal in Frameworks */ = {isa = PBXBuildFile; productRef = A6C0F37F2AC1E34F008C6AA7 /* WalletConnectModal */; }; @@ -45,16 +40,6 @@ A6D192D0293A7B97006B49F2 /* ConversationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D192CF293A7B97006B49F2 /* ConversationListView.swift */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - A6AE5185297B61AE006FDD0F /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = A6281987292DC825004B9117 /* Project object */; - proxyType = 1; - remoteGlobalIDString = A6AE517F297B61AE006FDD0F; - remoteInfo = NotificationService; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXCopyFilesBuildPhase section */ A65F0701297B5BCC00C3C76E /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; @@ -62,7 +47,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - A6AE5187297B61AE006FDD0F /* NotificationService.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -92,9 +76,6 @@ A68807142B6C53E0004340BD /* GroupDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupDetailView.swift; sourceTree = ""; }; A69F33C9292DD557005A5556 /* LoggedInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedInView.swift; sourceTree = ""; }; A69F33CB292DD568005A5556 /* QRCodeSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeSheetView.swift; sourceTree = ""; }; - A6AE5180297B61AE006FDD0F /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - A6AE518B297B61C8006FDD0F /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; - A6AE518C297B6210006FDD0F /* NotificationService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; A6C0F3832AC1E4B5008C6AA7 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; A6C0F3852AC1E549008C6AA7 /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; A6D192CF293A7B97006B49F2 /* ConversationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationListView.swift; sourceTree = ""; }; @@ -117,15 +98,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A6AE517D297B61AE006FDD0F /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - A6AE5194297B62C8006FDD0F /* KeychainAccess in Frameworks */, - A6494F002B6C2DF700D9FFB9 /* XMTPiOS in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -134,7 +106,6 @@ children = ( A6E774192B154D1E00F01DFF /* xmtp-ios */, A6281991292DC825004B9117 /* XMTPiOSExample */, - A6AE5181297B61AE006FDD0F /* NotificationService */, A6281990292DC825004B9117 /* Products */, A69F33C4292DC992005A5556 /* Frameworks */, ); @@ -144,7 +115,6 @@ isa = PBXGroup; children = ( A628198F292DC825004B9117 /* XMTPiOSExample.app */, - A6AE5180297B61AE006FDD0F /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -211,15 +181,6 @@ path = Views; sourceTree = ""; }; - A6AE5181297B61AE006FDD0F /* NotificationService */ = { - isa = PBXGroup; - children = ( - A6AE518C297B6210006FDD0F /* NotificationService.swift */, - A6AE518B297B61C8006FDD0F /* NotificationService.entitlements */, - ); - path = NotificationService; - sourceTree = ""; - }; A6C0F3872AC1E54F008C6AA7 /* Extensions */ = { isa = PBXGroup; children = ( @@ -244,7 +205,6 @@ buildRules = ( ); dependencies = ( - A6AE5186297B61AE006FDD0F /* PBXTargetDependency */, ); name = XMTPiOSExample; packageProductDependencies = ( @@ -261,27 +221,6 @@ productReference = A628198F292DC825004B9117 /* XMTPiOSExample.app */; productType = "com.apple.product-type.application"; }; - A6AE517F297B61AE006FDD0F /* NotificationService */ = { - isa = PBXNativeTarget; - buildConfigurationList = A6AE5188297B61AE006FDD0F /* Build configuration list for PBXNativeTarget "NotificationService" */; - buildPhases = ( - A6AE517C297B61AE006FDD0F /* Sources */, - A6AE517D297B61AE006FDD0F /* Frameworks */, - A6AE517E297B61AE006FDD0F /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = NotificationService; - packageProductDependencies = ( - A6AE5193297B62C8006FDD0F /* KeychainAccess */, - A6494EFF2B6C2DF700D9FFB9 /* XMTPiOS */, - ); - productName = NotificationService; - productReference = A6AE5180297B61AE006FDD0F /* NotificationService.appex */; - productType = "com.apple.product-type.app-extension"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -295,9 +234,6 @@ A628198E292DC825004B9117 = { CreatedOnToolsVersion = 14.1; }; - A6AE517F297B61AE006FDD0F = { - CreatedOnToolsVersion = 14.1; - }; }; }; buildConfigurationList = A628198A292DC825004B9117 /* Build configuration list for PBXProject "XMTPiOSExample" */; @@ -321,7 +257,6 @@ projectRoot = ""; targets = ( A628198E292DC825004B9117 /* XMTPiOSExample */, - A6AE517F297B61AE006FDD0F /* NotificationService */, ); }; /* End PBXProject section */ @@ -336,13 +271,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A6AE517E297B61AE006FDD0F /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -396,25 +324,8 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A6AE517C297B61AE006FDD0F /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A6AE518E297B6210006FDD0F /* NotificationService.swift in Sources */, - A6AE5192297B6270006FDD0F /* Persistence.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - A6AE5186297B61AE006FDD0F /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = A6AE517F297B61AE006FDD0F /* NotificationService */; - targetProxy = A6AE5185297B61AE006FDD0F /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin XCBuildConfiguration section */ A628199C292DC826004B9117 /* Debug */ = { isa = XCBuildConfiguration; @@ -606,63 +517,6 @@ }; name = Release; }; - A6AE5189297B61AE006FDD0F /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = FY4NZR34Z3; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NotificationService/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = NotificationService; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.xmtp.XMTPiOSExampleApp.NotificationService; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - A6AE518A297B61AE006FDD0F /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = FY4NZR34Z3; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NotificationService/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = NotificationService; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.xmtp.XMTPiOSExampleApp.NotificationService; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -684,15 +538,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A6AE5188297B61AE006FDD0F /* Build configuration list for PBXNativeTarget "NotificationService" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A6AE5189297B61AE006FDD0F /* Debug */, - A6AE518A297B61AE006FDD0F /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -744,10 +589,6 @@ package = 6AEE396C29F330CD0027B657 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; - A6494EFF2B6C2DF700D9FFB9 /* XMTPiOS */ = { - isa = XCSwiftPackageProductDependency; - productName = XMTPiOS; - }; A65F0706297B5E7600C3C76E /* WalletConnectSwift */ = { isa = XCSwiftPackageProductDependency; package = A65F0705297B5E7500C3C76E /* XCRemoteSwiftPackageReference "WalletConnectSwift" */; @@ -762,11 +603,6 @@ isa = XCSwiftPackageProductDependency; productName = XMTPiOS; }; - A6AE5193297B62C8006FDD0F /* KeychainAccess */ = { - isa = XCSwiftPackageProductDependency; - package = A65F0708297B5E8600C3C76E /* XCRemoteSwiftPackageReference "KeychainAccess" */; - productName = KeychainAccess; - }; A6C0F37A2AC1E321008C6AA7 /* Starscream */ = { isa = XCSwiftPackageProductDependency; package = A6C0F3792AC1E321008C6AA7 /* XCRemoteSwiftPackageReference "Starscream" */; diff --git a/XMTPiOSExample/XMTPiOSExample/ContentView.swift b/XMTPiOSExample/XMTPiOSExample/ContentView.swift index 7d50f7e5..33e4de7f 100644 --- a/XMTPiOSExample/XMTPiOSExample/ContentView.swift +++ b/XMTPiOSExample/XMTPiOSExample/ContentView.swift @@ -31,8 +31,6 @@ struct ContentView: View { .sheet(isPresented: $isConnectingWallet) { LoginView(onConnected: { client in do { - let keysData = try client.privateKeyBundle.serializedData() - Persistence().saveKeys(keysData) self.status = .connected(client) } catch { print("Error setting up client: \(error)") @@ -46,17 +44,18 @@ struct ContentView: View { Task { do { if let keysData = Persistence().loadKeys() { - let keys = try PrivateKeyBundle(serializedData: keysData) - let client = try await Client.from( - bundle: keys, - options: .init( - api: .init(env: .local, isSecure: false), - codecs: [GroupUpdatedCodec()], - enableV3: true + if let address = Persistence().loadAddress() { + let client = try await Client.build( + address: address, + options: .init( + api: .init(env: .dev, isSecure: true), + codecs: [GroupUpdatedCodec()], + dbEncryptionKey: keysData + ) ) - ) - await MainActor.run { - self.status = .connected(client) + await MainActor.run { + self.status = .connected(client) + } } } } catch { @@ -92,18 +91,18 @@ struct ContentView: View { Task { do { let wallet = try PrivateKey.generate() + let key = try secureRandomBytes(count: 32) + Persistence().saveKeys(key) + Persistence().saveAddress(wallet.address) let client = try await Client.create( account: wallet, options: .init( - api: .init(env: .local, isSecure: false, appVersion: "XMTPTest/v1.0.0"), + api: .init(env: .dev, isSecure: true, appVersion: "XMTPTest/v1.0.0"), codecs: [GroupUpdatedCodec()], - enableV3: true + dbEncryptionKey: key ) ) - let keysData = try client.privateKeyBundle.serializedData() - Persistence().saveKeys(keysData) - await MainActor.run { self.status = .connected(client) } diff --git a/XMTPiOSExample/XMTPiOSExample/Persistence.swift b/XMTPiOSExample/XMTPiOSExample/Persistence.swift index bc46fbec..c6f0b7ff 100644 --- a/XMTPiOSExample/XMTPiOSExample/Persistence.swift +++ b/XMTPiOSExample/XMTPiOSExample/Persistence.swift @@ -28,18 +28,31 @@ struct Persistence { return nil } } + + func saveAddress(_ address: String) { + keychain[string: "address"] = address + } - func load(conversationTopic: String) throws -> ConversationContainer? { - guard let data = try keychain.getData(key(topic: conversationTopic)) else { + func loadAddress() -> String? { + do { + return try keychain.getString("address") + } catch { + print("Error loading address data: \(error)") return nil } - - let decoder = JSONDecoder() - let decoded = try decoder.decode(ConversationContainer.self, from: data) - - return decoded } +// func load(conversationTopic: String) throws -> ConversationContainer? { +// guard let data = try keychain.getData(key(topic: conversationTopic)) else { +// return nil +// } +// +// let decoder = JSONDecoder() +// let decoded = try decoder.decode(ConversationContainer.self, from: data) +// +// return decoded +// } + func save(conversation: Conversation) throws { // keychain[data: key(topic: conversation.topic)] = try JSONEncoder().encode(conversation.encodedContainer) } diff --git a/XMTPiOSExample/XMTPiOSExample/Views/ConversationDetailView.swift b/XMTPiOSExample/XMTPiOSExample/Views/ConversationDetailView.swift index 4e81cd65..188e03f9 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/ConversationDetailView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/ConversationDetailView.swift @@ -43,7 +43,7 @@ struct ConversationDetailView: View { } } } - .navigationTitle((try? conversation.peerAddress) ?? "") + .navigationTitle((try? conversation.id) ?? "") .navigationBarTitleDisplayMode(.inline) } diff --git a/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift b/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift index 99ca3171..dad6998a 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/ConversationListView.swift @@ -1,10 +1,3 @@ -// -// ConversationListView.swift -// XMTPiOSExample -// -// Created by Pat Nakajima on 12/2/22. -// - import SwiftUI import XMTPiOS @@ -12,173 +5,149 @@ struct ConversationListView: View { var client: XMTPiOS.Client @EnvironmentObject var coordinator: EnvironmentCoordinator - @State private var conversations: [ConversationOrGroup] = [] + @State private var conversations: [XMTPiOS.Conversation] = [] @State private var isShowingNewConversation = false + // Pre-sorted conversations to reduce complexity + private var sortedConversations: [XMTPiOS.Conversation] { + conversations.sorted(by: compareConversations) + } + var body: some View { List { - ForEach(conversations.sorted(by: { $0.createdAt > $1.createdAt }), id: \.id) { item in - NavigationLink(value: item) { - HStack { - switch item { - case .conversation: - Image(systemName: "person.fill") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundStyle(.secondary) - case .group: - Image(systemName: "person.3.fill") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundStyle(.secondary) - } - - VStack(alignment: .leading) { - switch item { - case .conversation(let conversation): - if let abbreviatedAddress = try? Util.abbreviate(address: conversation.peerAddress) { - Text(abbreviatedAddress) - } else { - Text("Unknown Address") - .foregroundStyle(.secondary) - } - case .group(let group): - let memberAddresses = try? group.members.map(\.inboxId).sorted().map { Util.abbreviate(address: $0) } - if let addresses = memberAddresses { - Text(addresses.joined(separator: ", ")) - } else { - Text("Unknown Members") - .foregroundStyle(.secondary) - } - } - - Text(item.createdAt.formatted()) - .font(.caption) - .foregroundStyle(.secondary) - } - } + ForEach(sortedConversations, id: \.id) { item in + NavigationLink(destination: destinationView(for: item)) { + conversationRow(for: item) } } } - .navigationDestination(for: ConversationOrGroup.self) { item in - switch item { - case .conversation(let conversation): - ConversationDetailView(client: client, conversation: conversation) - case .group(let group): - GroupDetailView(client: client, group: group) - } - } .navigationTitle("Conversations") - .refreshable { - await loadConversations() - } - .task { - await loadConversations() - } - .task { - do { - for try await group in try await client.conversations.streamGroups() { - conversations.insert(.group(group), at: 0) - - await add(conversations: [.group(group)]) - } - - } catch { - print("Error streaming groups: \(error)") - } - } - .task { - do { - for try await conversation in try await client.conversations.stream() { - conversations.insert(.conversation(conversation), at: 0) - - await add(conversations: [.conversation(conversation)]) - } - - } catch { - print("Error streaming conversations: \(error)") - } - } + .refreshable { await loadConversations() } + .task { await loadConversations() } + .task { await startConversationStream() } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - self.isShowingNewConversation = true - }) { + Button(action: { isShowingNewConversation = true }) { Label("New Conversation", systemImage: "plus") } } } .sheet(isPresented: $isShowingNewConversation) { NewConversationView(client: client) { conversationOrGroup in - switch conversationOrGroup { - case .conversation(let conversation): - conversations.insert(.conversation(conversation), at: 0) - coordinator.path.append(conversationOrGroup) - case .group(let group): - conversations.insert(.group(group), at: 0) - coordinator.path.append(conversationOrGroup) - } + addConversation(conversationOrGroup) } } } - func loadConversations() async { - do { - let conversations = try await client.conversations.list().map { - ConversationOrGroup.conversation($0) + // Helper function to compare conversations by createdAt date + private func compareConversations(_ lhs: XMTPiOS.Conversation, _ rhs: XMTPiOS.Conversation) -> Bool { + return lhs.createdAt > rhs.createdAt + } + + // Extracted row view for each conversation + @ViewBuilder + private func conversationRow(for item: XMTPiOS.Conversation) -> some View { + HStack { + conversationIcon(for: item) + VStack(alignment: .leading) { + Text(conversationDisplayName(for: item)) + .foregroundStyle(.secondary) + Text(formattedDate(for: item.createdAt)) + .font(.caption) + .foregroundStyle(.secondary) } + } + } - try await client.conversations.sync() + // Extracted icon view for conversation type + @ViewBuilder + private func conversationIcon(for item: XMTPiOS.Conversation) -> some View { + switch item { + case .dm: + Image(systemName: "person.fill") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundStyle(.secondary) + case .group: + Image(systemName: "person.3.fill") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundStyle(.secondary) + } + } - let groups = try await client.conversations.groups().map { - ConversationOrGroup.group($0) - } + // Helper function to provide a display name based on the conversation type + private func conversationDisplayName(for item: XMTPiOS.Conversation) -> String { + switch item { + case .dm(let conversation): + return (try? Util.abbreviate(address: conversation.peerInboxId)) ?? "Unknown Address" + case .group(let group): + return (try? group.groupName()) ?? "Group Name" + } + } + + // Helper function to format the date + private func formattedDate(for date: Date) -> String { + return date.formatted() + } + + // Define destination view based on conversation type + @ViewBuilder + private func destinationView(for item: XMTPiOS.Conversation) -> some View { + switch item { + case .dm(let conversation): + ConversationDetailView(client: client, conversation: .dm(conversation)) + case .group(let group): + GroupDetailView(client: client, group: group) + } + } + // Async function to load conversations + func loadConversations() async { + do { + try await client.conversations.sync() + let loadedConversations = try await client.conversations.list() await MainActor.run { - self.conversations = conversations + groups + self.conversations = loadedConversations } - - await add(conversations: conversations) + await add(conversations: loadedConversations) } catch { print("Error loading conversations: \(error)") } } - func add(conversations: [ConversationOrGroup]) async { - for conversationOrGroup in conversations { - switch conversationOrGroup { - case .conversation(let conversation): - // Ensure we're subscribed to push notifications on these conversations - do { - let hmacKeysResult = await client.conversations.getHmacKeys() - let hmacKeys = hmacKeysResult.hmacKeys - - let result = hmacKeys[conversation.topic]?.values.map { hmacKey -> NotificationSubscriptionHmacKey in - NotificationSubscriptionHmacKey.with { sub_key in - sub_key.key = hmacKey.hmacKey - sub_key.thirtyDayPeriodsSinceEpoch = UInt32(hmacKey.thirtyDayPeriodsSinceEpoch) - } - } - - let subscription = NotificationSubscription.with { sub in - sub.hmacKeys = result ?? [] - sub.topic = conversation.topic - sub.isSilent = conversation.version == .v1 - } - try await XMTPPush.shared.subscribeWithMetadata(subscriptions: [subscription]) - } catch { - print("Error subscribing: \(error)") + // Async function to stream conversations + func startConversationStream() async { + do { + for try await conversation in try await client.conversations.stream() { + await MainActor.run { + conversations.insert(conversation, at: 0) } + await add(conversations: [conversation]) + } + } catch { + print("Error streaming conversations: \(error)") + } + } - do { - try Persistence().save(conversation: conversation) - } catch { - print("Error saving \(conversation.topic): \(error)") - } - case .group: - // Handle this in the future + // Helper function to add a conversation or group + private func addConversation(_ conversationOrGroup: XMTPiOS.Conversation) { + switch conversationOrGroup { + case .dm(let conversation): + conversations.insert(.dm(conversation), at: 0) + coordinator.path.append(conversationOrGroup) + case .group(let group): + conversations.insert(.group(group), at: 0) + coordinator.path.append(conversationOrGroup) + } + } + + func add(conversations: [XMTPiOS.Conversation]) async { + for conversationOrGroup in conversations { + switch conversationOrGroup { + case .dm, .group: return } } @@ -187,11 +156,9 @@ struct ConversationListView: View { struct ConversationListView_Previews: PreviewProvider { static var previews: some View { - VStack { - PreviewClientProvider { client in - NavigationView { - ConversationListView(client: client) - } + PreviewClientProvider { client in + NavigationStack { + ConversationListView(client: client) } } } diff --git a/XMTPiOSExample/XMTPiOSExample/Views/GroupSettingsView.swift b/XMTPiOSExample/XMTPiOSExample/Views/GroupSettingsView.swift index 83b8cbc1..b9b00a9d 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/GroupSettingsView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/GroupSettingsView.swift @@ -1,10 +1,3 @@ -// -// GroupSettingsView.swift -// XMTPiOSExample -// -// Created by Pat Nakajima on 2/6/24. -// - import SwiftUI import XMTPiOS @@ -42,9 +35,7 @@ struct GroupSettingsView: View { if client.address.lowercased() == member.lowercased() { Button("Leave", role: .destructive) { Task { - try await group.removeMembers(addresses: [client.address]) - coordinator.path = NavigationPath() - dismiss() + try? await leaveGroup() } } } else { @@ -61,34 +52,8 @@ struct GroupSettingsView: View { HStack { TextField("Add member", text: $newGroupMember) Button("Add") { - if newGroupMember.lowercased() == client.address { - self.groupError = "You cannot add yourself to a group" - return - } - - isAddingMember = true - Task { - do { - if try await self.client.canMessageV3(address: newGroupMember) { - try await group.addMembers(addresses: [newGroupMember]) - try await syncGroupMembers() - - await MainActor.run { - self.groupError = "" - self.newGroupMember = "" - self.isAddingMember = false - } - } else { - await MainActor.run { - self.groupError = "Member address not registered" - self.isAddingMember = false - } - } - } catch { - self.groupError = error.localizedDescription - self.isAddingMember = false - } + await addMember() } } .opacity(isAddingMember ? 0 : 1) @@ -100,7 +65,7 @@ struct GroupSettingsView: View { } } - if groupError != "" { + if !groupError.isEmpty { Text(groupError) .foregroundStyle(.red) .font(.subheadline) @@ -114,9 +79,48 @@ struct GroupSettingsView: View { } private func syncGroupMembers() async throws { - try? await group.sync() - try await MainActor.run { - self.groupMembers = try group.members.map(\.inboxId) + try await group.sync() + let inboxIds = try await group.members.map(\.inboxId) + await MainActor.run { + self.groupMembers = inboxIds + } + } + + private func leaveGroup() async throws { + try await group.removeMembers(addresses: [client.address]) + await MainActor.run { + coordinator.path = NavigationPath() + dismiss() + } + } + + private func addMember() async { + guard newGroupMember.lowercased() != client.address else { + groupError = "You cannot add yourself to a group" + return + } + + isAddingMember = true + do { + if try await client.canMessage(address: newGroupMember) { + try await group.addMembers(addresses: [newGroupMember]) + try await syncGroupMembers() + await MainActor.run { + groupError = "" + newGroupMember = "" + isAddingMember = false + } + } else { + await MainActor.run { + groupError = "Member address not registered" + isAddingMember = false + } + } + } catch { + await MainActor.run { + groupError = error.localizedDescription + isAddingMember = false + } } } } diff --git a/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift b/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift index d47f90c4..ebd27837 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/LoginView.swift @@ -161,12 +161,15 @@ struct LoginView: View { Task(priority: .high) { let signer = Signer(session: session, account: account) + let key = try secureRandomBytes(count: 32) + Persistence().saveKeys(key) + Persistence().saveAddress(signer.address) let client = try await Client.create( account: signer, options: .init( api: .init(env: .local, isSecure: false), codecs: [GroupUpdatedCodec()], - enableV3: true + dbEncryptionKey: key ) ) diff --git a/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift b/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift index e538603b..1a9c40fe 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/NewConversationView.swift @@ -8,40 +8,9 @@ import SwiftUI import XMTPiOS -enum ConversationOrGroup: Hashable { - - case conversation(Conversation), group(XMTPiOS.Group) - - static func == (lhs: ConversationOrGroup, rhs: ConversationOrGroup) throws -> Bool { - try lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) throws { - try id.hash(into: &hasher) - } - - var id: String { - switch self { - case .conversation(let conversation): - return conversation.topic - case .group(let group): - return group.id.toHexEncodedString() - } - } - - var createdAt: Date { - switch self { - case .conversation(let conversation): - return conversation.createdAt - case .group(let group): - return group.createdAt - } - } -} - struct NewConversationView: View { var client: XMTPiOS.Client - var onCreate: (ConversationOrGroup) -> Void + var onCreate: (XMTPiOS.Conversation) -> Void @Environment(\.dismiss) var dismiss @State private var recipientAddress: String = "" @@ -84,7 +53,7 @@ struct NewConversationView: View { Task { do { - if try await self.client.canMessageV3(address: newGroupMember) { + if try await self.client.canMessage(address: newGroupMember) { await MainActor.run { self.groupError = "" self.groupMembers.append(newGroupMember) @@ -164,9 +133,9 @@ struct NewConversationView: View { let conversation = try await client.conversations.newConversation(with: address) await MainActor.run { dismiss() - onCreate(.conversation(conversation)) + onCreate(conversation) } - } catch ConversationError.recipientNotOnNetwork { + } catch ConversationError.memberNotRegistered([address]) { await MainActor.run { self.error = "Recipient is not on the XMTP network." } diff --git a/XMTPiOSExample/XMTPiOSExample/Views/PreviewClientProvider.swift b/XMTPiOSExample/XMTPiOSExample/Views/PreviewClientProvider.swift index b3ff40f7..47460154 100644 --- a/XMTPiOSExample/XMTPiOSExample/Views/PreviewClientProvider.swift +++ b/XMTPiOSExample/XMTPiOSExample/Views/PreviewClientProvider.swift @@ -42,9 +42,12 @@ struct PreviewClientProvider: View { Text("Creating client…") .task { do { - var options = ClientOptions() - options.api.env = .local - options.api.isSecure = false + let key = try secureRandomBytes(count: 32) + Persistence().saveKeys(key) + Persistence().saveAddress(wallet.address) + var options = ClientOptions(dbEncryptionKey: key) + options.api.env = .dev + options.api.isSecure = true let client = try await Client.create(account: wallet, options: options) await MainActor.run { self.client = client @@ -66,3 +69,21 @@ struct PreviewClientProvider_Previews: PreviewProvider { } } } + +func secureRandomBytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + + // Fill bytes with secure random data + let status = SecRandomCopyBytes( + kSecRandomDefault, + count, + &bytes + ) + + // A status of errSecSuccess indicates success + if status == errSecSuccess { + return Data(bytes) + } else { + fatalError("could not generate random bytes") + } +}