diff --git a/android/build.gradle b/android/build.gradle index 685cd6ac..5a9df29a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -95,7 +95,7 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" - implementation "org.xmtp:android:4.9.0" + implementation "org.xmtp:android:4.10.0-rc2" implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.facebook.react:react-native:0.71.3' implementation "com.daveanthonythomas.moshipack:moshipack:1.0.1" diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 98683e72..53a93534 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -13,6 +13,7 @@ import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.xmtpreactnativesdk.wrappers.ArchiveMetadataWrapper +import expo.modules.xmtpreactnativesdk.wrappers.AvailableArchiveWrapper import expo.modules.xmtpreactnativesdk.wrappers.AuthParamsWrapper import expo.modules.xmtpreactnativesdk.wrappers.ClientWrapper import expo.modules.xmtpreactnativesdk.wrappers.ConsentWrapper @@ -226,12 +227,6 @@ class XMTPModule : Module() { dbEncryptionKey.foldIndexed(ByteArray(dbEncryptionKey.size)) { i, a, v -> a.apply { set(i, v.toByte()) } } - val historySyncUrl = authOptions.historySyncUrl - ?: when (authOptions.environment) { - "production" -> "https://message-history.production.ephemera.network/" - "local" -> "http://10.0.2.2:5558" - else -> "https://message-history.dev.ephemera.network/" - } return ClientOptions( api = apiEnvironments( authOptions.environment, @@ -243,7 +238,6 @@ class XMTPModule : Module() { appContext = context, dbEncryptionKey = encryptionKeyBytes, dbDirectory = authOptions.dbDirectory, - historySyncUrl = historySyncUrl, deviceSyncEnabled = authOptions.deviceSyncEnabled, forkRecoveryOptions = authOptions.forkRecoveryOptions ) @@ -2177,6 +2171,39 @@ class XMTPModule : Module() { ArchiveMetadataWrapper.encode(metadata) } } + + AsyncFunction("sendSyncArchive") Coroutine { installationId: String, pin: String, serverUrl: String?, startNs: Long?, endNs: Long?, archiveElements: List?, excludeDisappearingMessages: Boolean? -> + withContext(Dispatchers.IO) { + val client = clients[installationId] ?: throw XMTPException("No client") + val elements = archiveElements?.map { getArchiveElement(it) } ?: listOf(ArchiveElement.MESSAGES, ArchiveElement.CONSENT) + val opts = ArchiveOptions(startNs, endNs, elements, excludeDisappearingMessages ?: false) + val url = serverUrl?.takeIf { it.isNotBlank() } ?: client.environment.getHistorySyncUrl() + client.sendSyncArchive(opts, url, pin) + } + } + + AsyncFunction("processSyncArchive") Coroutine { installationId: String, archivePin: String? -> + withContext(Dispatchers.IO) { + val client = clients[installationId] ?: throw XMTPException("No client") + client.processSyncArchive(archivePin) + } + } + + AsyncFunction("listAvailableArchives") Coroutine { installationId: String, daysCutoff: Long -> + withContext(Dispatchers.IO) { + val client = clients[installationId] ?: throw XMTPException("No client") + val archives = client.listAvailableArchives(daysCutoff) + AvailableArchiveWrapper.encodeList(archives) + } + } + + AsyncFunction("syncAllDeviceSyncGroups") Coroutine { installationId: String -> + withContext(Dispatchers.IO) { + val client = clients[installationId] ?: throw XMTPException("No client") + val summary = client.syncAllDeviceSyncGroups() + GroupSyncSummaryWrapper.encode(summary) + } + } } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ArchiveMetadataWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ArchiveMetadataWrapper.kt index 64e21334..7142c9e4 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ArchiveMetadataWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ArchiveMetadataWrapper.kt @@ -32,11 +32,11 @@ class ArchiveMetadataWrapper { } } - fun encode(metadata: ArchiveMetadata?): String { - val obj = if (metadata != null) { + /** Returns metadata as a map for embedding in another JSON object (avoids double JSON encoding). */ + fun encodeToMap(metadata: ArchiveMetadata?): Map { + return if (metadata != null) { encodeToObj(metadata) } else { - // Create a default metadata object when null mapOf( "archiveVersion" to 0u, "elements" to listOf("messages", "consent"), @@ -45,7 +45,10 @@ class ArchiveMetadataWrapper { "endNs" to null ) } - return gson.toJson(obj) + } + + fun encode(metadata: ArchiveMetadata?): String { + return gson.toJson(encodeToMap(metadata)) } } } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AuthParamsWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AuthParamsWrapper.kt index 9284ba79..ac4895f7 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AuthParamsWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AuthParamsWrapper.kt @@ -10,7 +10,6 @@ import java.math.BigInteger class AuthParamsWrapper( val environment: String, val dbDirectory: String?, - val historySyncUrl: String?, val customLocalUrl: String?, val deviceSyncEnabled: Boolean, val debugEventsEnabled: Boolean, @@ -83,7 +82,6 @@ class AuthParamsWrapper( return AuthParamsWrapper( jsonOptions.get("environment").asString, if (jsonOptions.has("dbDirectory")) jsonOptions.get("dbDirectory").asString else null, - if (jsonOptions.has("historySyncUrl")) jsonOptions.get("historySyncUrl").asString else null, if (jsonOptions.has("customLocalUrl")) jsonOptions.get("customLocalUrl").asString else null, if (jsonOptions.has("deviceSyncEnabled")) jsonOptions.get("deviceSyncEnabled").asBoolean else true, if (jsonOptions.has("debugEventsEnabled")) jsonOptions.get("debugEventsEnabled").asBoolean else false, diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AvailableArchiveWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AvailableArchiveWrapper.kt new file mode 100644 index 00000000..e437a936 --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/AvailableArchiveWrapper.kt @@ -0,0 +1,22 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import android.util.Base64 +import com.google.gson.GsonBuilder +import org.xmtp.android.library.libxmtp.AvailableArchive + +class AvailableArchiveWrapper { + companion object { + private val gson = GsonBuilder().create() + + fun encodeToObj(archive: AvailableArchive): Map = mapOf( + "pin" to archive.pin, + "metadata" to ArchiveMetadataWrapper.encodeToMap(archive.metadata), + "sentByInstallation" to Base64.encodeToString(archive.sentByInstallation, Base64.NO_WRAP), + ) + + fun encodeList(archives: List): String { + val list = archives.map { encodeToObj(it) } + return gson.toJson(list) + } + } +} diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 92003bd9..390a2799 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - Connect-Swift (1.2.0): + - Connect-Swift (1.2.1): - SwiftProtobuf (~> 1.30.0) - CryptoSwift (1.8.3) - CSecp256k1 (0.2.0) @@ -1757,7 +1757,7 @@ PODS: - SQLCipher/standard (4.5.7): - SQLCipher/common - SwiftProtobuf (1.30.0) - - XMTP (4.9.0): + - XMTP (4.10.0-rc2): - Connect-Swift (~> 1.2.0) - CryptoSwift (= 1.8.3) - SQLCipher (= 4.5.7) @@ -1766,7 +1766,7 @@ PODS: - ExpoModulesCore - MessagePacker - SQLCipher (= 4.5.7) - - XMTP (= 4.9.0) + - XMTP (= 4.10.0-rc2) - Yoga (0.0.0) DEPENDENCIES: @@ -2077,7 +2077,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - Connect-Swift: 82bcc0834587bd537f17a9720f62ea9fc7d9f3a5 + Connect-Swift: 8550afdb45aaea9f527d29c74f7224bf78e2bc26 CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483 CSecp256k1: 2a59c03e52637ded98896a33be4b2649392cb843 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 @@ -2179,8 +2179,8 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SQLCipher: 5e6bfb47323635c8b657b1b27d25c5f1baf63bf5 SwiftProtobuf: 3697407f0d5b23bedeba9c2eaaf3ec6fdff69349 - XMTP: 322f5be971dca2b1f402727ffda2f62d4ab7f71d - XMTPReactNative: 44d7e20a4affcf6e3c7c62c2331c2dcb9696c934 + XMTP: 40a323abd37322a4d0c323a1fd97e9a67ce9c16f + XMTPReactNative: f62ff8302fb02c611cae0be0981021d6f3426036 Yoga: 40f19fff64dce86773bf8b602c7070796c007970 PODFILE CHECKSUM: c76510e65e7d9673f44024ae2d0a10eec063a555 diff --git a/example/src/tests/historySyncTests.ts b/example/src/tests/historySyncTests.ts index 0e2ce9f2..fadbcaeb 100644 --- a/example/src/tests/historySyncTests.ts +++ b/example/src/tests/historySyncTests.ts @@ -20,7 +20,7 @@ function test(name: string, perform: () => Promise) { }) } -test('can sync consent (expected to fail unless historySyncUrl is set)', async () => { +test('can sync consent', async () => { const [bo] = await createClients(1) const keyBytes = new Uint8Array([ 233, 120, 198, 96, 154, 65, 132, 17, 132, 96, 250, 40, 103, 35, 125, 64, @@ -47,12 +47,13 @@ test('can sync consent (expected to fail unless historySyncUrl is set)', async ( // Create DM conversation const dm = await alix.conversations.findOrCreateDm(bo.inboxId) - await dm.updateConsent('denied') - const consentState = await dm.consentState() - assert(consentState === 'denied', `Expected 'denied', got ${consentState}`) + const initialConsent = await dm.consentState() + assert( + initialConsent === 'unknown' || initialConsent === 'allowed', + `Expected initial consent unknown or allowed, got ${initialConsent}` + ) await bo.conversations.sync() - const boDm = await bo.conversations.findConversation(dm.id) const alix2 = await Client.create(adaptEthersWalletToSigner(alixWallet), { env: 'local', @@ -66,27 +67,54 @@ test('can sync consent (expected to fail unless historySyncUrl is set)', async ( `Expected 2 installations, got ${state.installations.length}` ) - // Sync conversations - await bo.conversations.sync() - if (boDm) await boDm.sync() - await alix2.preferences.sync() + // Sync the DM on alix so conversation is pushed + await dm.sync() + await delayToPropogate(1000) await alix.conversations.syncAllConversations() - await delayToPropogate(2000) - await alix2.conversations.syncAllConversations() - await delayToPropogate(2000) + await delayToPropogate(1000) + + // Alix2 syncs so it has the DM (mirrors Android alixClient2.conversations.sync()) + await alix2.conversations.sync() + await delayToPropogate(1000) + + const dm2Initial = await alix2.conversations.findConversation(dm.id) + if (!dm2Initial) { + throw new Error( + `Failed to find DM with ID: ${dm.id} on alix2 before consent update` + ) + } + const consentOnAlix2Before = await dm2Initial.consentState() + assert( + consentOnAlix2Before === 'unknown' || consentOnAlix2Before === 'allowed', + `Expected alix2 consent unknown/allowed before update, got ${consentOnAlix2Before}` + ) + + // Now update consent to denied on alix (same order as Android: after both have the convo) + await dm.updateConsent('denied') + const consentState = await dm.consentState() + assert(consentState === 'denied', `Expected 'denied', got ${consentState}`) + + await alix.preferences.sync() + await delayToPropogate(1000) + await alix2.preferences.sync() + // Longer delay before asserting consent propagation (Android uses delay(4000)) + await delayToPropogate(4000) const dm2 = await alix2.conversations.findConversation(dm.id) - const consentState2 = await dm2?.consentState() + if (!dm2) { + throw new Error(`Failed to find DM with ID: ${dm.id} on alix2`) + } + const consentState2 = await dm2.consentState() assert(consentState2 === 'denied', `Expected 'denied', got ${consentState2}`) await alix2.preferences.setConsentState( - new ConsentRecord(dm2!.id, 'conversation_id', 'allowed') + new ConsentRecord(dm2.id, 'conversation_id', 'allowed') ) - const convoState = await alix2.preferences.conversationConsentState(dm2!.id) + const convoState = await alix2.preferences.conversationConsentState(dm2.id) assert(convoState === 'allowed', `Expected 'allowed', got ${convoState}`) - const updatedConsentState = await dm2?.consentState() + const updatedConsentState = await dm2.consentState() assert( updatedConsentState === 'allowed', `Expected 'allowed', got ${updatedConsentState}` @@ -104,7 +132,6 @@ test('can stream consent (expected to fail unless historySyncUrl is set)', async const dbDirPath = `${RNFS.DocumentDirectoryPath}/xmtp_db` const dbDirPath2 = `${RNFS.DocumentDirectoryPath}/xmtp_db2` - // Ensure the directories exist if (!(await RNFS.exists(dbDirPath))) { await RNFS.mkdir(dbDirPath) } @@ -120,39 +147,43 @@ test('can stream consent (expected to fail unless historySyncUrl is set)', async dbDirectory: dbDirPath, }) - const alixGroup = await alix.conversations.newGroup([bo.inboxId]) - const alix2 = await Client.create(adaptEthersWalletToSigner(alixWallet), { env: 'local', dbEncryptionKey: keyBytes, dbDirectory: dbDirPath2, }) - await alixGroup.send('Hello') + const alixGroup = await alix.conversations.newGroup([bo.inboxId]) + await alix.conversations.syncAllConversations() + await delayToPropogate(2000) await alix2.conversations.syncAllConversations() + await delayToPropogate(2000) - const alix2Group = await alix2.conversations.findConversation(alixGroup.id) - await delayToPropogate() + const alix2Group = await alix2.conversations.findGroup(alixGroup.id) + if (!alix2Group) { + throw new Error(`Failed to find group with ID: ${alixGroup.id} on alix2`) + } - const consent = [] + const consent: ConsentRecord[] = [] await alix.preferences.streamConsent(async (entry: ConsentRecord) => { consent.push(entry) }) + await alix.conversations.streamAllMessages(async () => { + // Keep stream active (mirrors Android job1) + }) - await delayToPropogate() + await delayToPropogate(2000) - await alix2Group!.updateConsent('denied') - const dm = await alix2.conversations.newConversation(bo.inboxId) - await dm!.updateConsent('denied') + await alix2Group.updateConsent('denied') + await alix2.preferences.sync() + await delayToPropogate(2000) - await delayToPropogate(3000) - await alix.conversations.syncAllConversations() - await alix2.conversations.syncAllConversations() + await delayToPropogate(2000) assert( - consent.length === 4, - `Expected 4 consent records, got ${consent.length}` + consent.length === 1, + `Expected 1 consent record on stream, got ${consent.length}` ) const updatedConsentState = await alixGroup.consentState() assert( @@ -161,11 +192,12 @@ test('can stream consent (expected to fail unless historySyncUrl is set)', async ) alix.preferences.cancelStreamConsent() + alix.conversations.cancelStreamAllMessages() return true }) -test('can preference updates (expected to fail unless historySyncUrl is set)', async () => { +test('can stream preference updates', async () => { const keyBytes = new Uint8Array([ 233, 120, 198, 96, 154, 65, 132, 17, 132, 96, 250, 40, 103, 35, 125, 64, 166, 83, 208, 224, 254, 44, 205, 227, 175, 49, 234, 129, 74, 252, 135, 145, @@ -208,11 +240,168 @@ test('can preference updates (expected to fail unless historySyncUrl is set)', a await delayToPropogate(2000) assert( - types.length === 2, - `Expected 2 preference update, got ${types.length}` + types.length === 1, + `Expected 1 preference update, got ${types.length}` ) alix.preferences.cancelStreamConsent() return true }) + +test('can sync device archive (sendSyncArchive, listAvailableArchives, processSyncArchive)', async () => { + const [bo] = await createClients(1) + const keyBytes = new Uint8Array([ + 233, 120, 198, 96, 154, 65, 132, 17, 132, 96, 250, 40, 103, 35, 125, 64, + 166, 83, 208, 224, 254, 44, 205, 227, 175, 49, 234, 129, 74, 252, 135, 145, + ]) + const dbDirPath = `${RNFS.DocumentDirectoryPath}/xmtp_db_sync_archive_1` + const dbDirPath2 = `${RNFS.DocumentDirectoryPath}/xmtp_db_sync_archive_2` + + if (!(await RNFS.exists(dbDirPath))) { + await RNFS.mkdir(dbDirPath) + } + if (!(await RNFS.exists(dbDirPath2))) { + await RNFS.mkdir(dbDirPath2) + } + + const alixWallet = Wallet.createRandom() + + const alix = await Client.create(adaptEthersWalletToSigner(alixWallet), { + env: 'local', + dbEncryptionKey: keyBytes, + dbDirectory: dbDirPath, + }) + + const group = await alix.conversations.newGroup([bo.inboxId]) + const msgFromAlix = await group.send('hello from alix') + + await delayToPropogate(1000) + + const alix2 = await Client.create(adaptEthersWalletToSigner(alixWallet), { + env: 'local', + dbEncryptionKey: keyBytes, + dbDirectory: dbDirPath2, + }) + + await delayToPropogate(1000) + + await alix.syncAllDeviceSyncGroups() + await alix.sendSyncArchive('123') + await delayToPropogate(1000) + + await bo.conversations.syncAllConversations() + const boGroup = await bo.conversations.findGroup(group.id) + if (!boGroup) throw new Error(`Failed to find group with ID: ${group.id}`) + await boGroup.send('hello from bo') + + await alix.conversations.syncAllConversations() + await alix2.conversations.syncAllConversations() + + const group2Before = await alix2.conversations.findGroup(group.id) + if (!group2Before) + throw new Error(`Failed to find group with ID: ${group.id}`) + const messagesBefore = await group2Before.messages() + assert( + messagesBefore.length === 2, + `Expected 2 messages before processSyncArchive, got ${messagesBefore.length}` + ) + + await delayToPropogate(1000) + await alix.syncAllDeviceSyncGroups() + await delayToPropogate(1000) + await alix2.syncAllDeviceSyncGroups() + + // Mirrors Swift/Kotlin test flow: archive listing is observed but not asserted + await alix2.listAvailableArchives(7) + + await alix2.processSyncArchive('123') + await alix2.conversations.syncAllConversations() + + const group2After = await alix2.conversations.findGroup(group.id) + if (!group2After) throw new Error(`Failed to find group with ID: ${group.id}`) + const messagesAfter = await group2After.messages() + assert( + messagesAfter.length === 3, + `Expected 3 messages after processSyncArchive, got ${messagesAfter.length}` + ) + assert( + messagesAfter.some((m) => m.id === msgFromAlix), + `Expected to find message with id ${msgFromAlix} in messages after sync` + ) + + return true +}) + +test('can sync messages across installations (sendSyncRequest, syncAllDeviceSyncGroups)', async () => { + const [bo] = await createClients(1) + const keyBytes = new Uint8Array([ + 233, 120, 198, 96, 154, 65, 132, 17, 132, 96, 250, 40, 103, 35, 125, 64, + 166, 83, 208, 224, 254, 44, 205, 227, 175, 49, 234, 129, 74, 252, 135, 145, + ]) + const dbDirPath = `${RNFS.DocumentDirectoryPath}/xmtp_db_sync_messages_1` + const dbDirPath2 = `${RNFS.DocumentDirectoryPath}/xmtp_db_sync_messages_2` + + if (!(await RNFS.exists(dbDirPath))) { + await RNFS.mkdir(dbDirPath) + } + if (!(await RNFS.exists(dbDirPath2))) { + await RNFS.mkdir(dbDirPath2) + } + + const alixWallet = Wallet.createRandom() + + const client1 = await Client.create(adaptEthersWalletToSigner(alixWallet), { + env: 'local', + dbEncryptionKey: keyBytes, + dbDirectory: dbDirPath, + }) + + const group = await client1.conversations.newGroup([bo.inboxId]) + + // Send a message before second installation is created + const msgId = await group.send('hi') + const messageCount = (await group.messages()).length + assert( + messageCount === 2, + `Expected 2 messages (group + hi), got ${messageCount}` + ) + + const client2 = await Client.create(adaptEthersWalletToSigner(alixWallet), { + env: 'local', + dbEncryptionKey: keyBytes, + dbDirectory: dbDirPath2, + }) + + const state = await client2.inboxState(true) + assert( + state.installations.length === 2, + `Expected 2 installations, got ${state.installations.length}` + ) + + await client2.sendSyncRequest() + + await client1.syncAllDeviceSyncGroups() + await delayToPropogate(1000) + await client2.syncAllDeviceSyncGroups() + await delayToPropogate(1000) + + const client1MessageCount = (await group.messages()).length + const group2 = await client2.conversations.findGroup(group.id) + if (!group2) throw new Error(`Failed to find group with ID: ${group.id}`) + + const messages = await group2.messages() + const containsMessage = messages.some((m) => m.id === msgId) + const client2MessageCount = messages.length + + assert( + containsMessage, + `Expected to find message with id ${msgId} in client2 messages` + ) + assert( + client1MessageCount === client2MessageCount, + `Expected client1 and client2 message counts to match: client1=${client1MessageCount}, client2=${client2MessageCount}` + ) + + return true +}) diff --git a/ios/Wrappers/AuthParamsWrapper.swift b/ios/Wrappers/AuthParamsWrapper.swift index 363d7a4b..e3430918 100644 --- a/ios/Wrappers/AuthParamsWrapper.swift +++ b/ios/Wrappers/AuthParamsWrapper.swift @@ -12,7 +12,6 @@ import XMTP struct AuthParamsWrapper { let environment: String let dbDirectory: String? - let historySyncUrl: String? let customLocalUrl: String? let deviceSyncEnabled: Bool let debugEventsEnabled: Bool @@ -22,14 +21,13 @@ struct AuthParamsWrapper { init( environment: String, dbDirectory: String?, - historySyncUrl: String?, customLocalUrl: String?, + customLocalUrl: String?, deviceSyncEnabled: Bool, debugEventsEnabled: Bool, appVersion: String?, gatewayHost: String?, forkRecoveryOptions: ForkRecoveryOptions? ) { self.environment = environment self.dbDirectory = dbDirectory - self.historySyncUrl = historySyncUrl self.customLocalUrl = customLocalUrl self.deviceSyncEnabled = deviceSyncEnabled self.debugEventsEnabled = debugEventsEnabled @@ -81,7 +79,7 @@ struct AuthParamsWrapper { else { return AuthParamsWrapper( environment: "dev", dbDirectory: nil, - historySyncUrl: nil, customLocalUrl: nil, + customLocalUrl: nil, deviceSyncEnabled: true, debugEventsEnabled: false, appVersion: nil, gatewayHost: nil, forkRecoveryOptions: nil @@ -90,10 +88,6 @@ struct AuthParamsWrapper { let environment = jsonOptions["environment"] as? String ?? "dev" let dbDirectory = jsonOptions["dbDirectory"] as? String - let historySyncUrl = jsonOptions["historySyncUrl"] as? String - if let historySyncUrl = historySyncUrl { - setenv("XMTP_HISTORY_SERVER_ADDRESS", historySyncUrl, 1) - } let customLocalUrl = jsonOptions["customLocalUrl"] as? String if let customLocalUrl = customLocalUrl { setenv("XMTP_NODE_ADDRESS", customLocalUrl, 1) @@ -121,7 +115,6 @@ struct AuthParamsWrapper { return AuthParamsWrapper( environment: environment, dbDirectory: dbDirectory, - historySyncUrl: historySyncUrl, customLocalUrl: customLocalUrl, deviceSyncEnabled: deviceSyncEnabled, debugEventsEnabled: debugEventsEnabled, diff --git a/ios/Wrappers/AvailableArchiveWrapper.swift b/ios/Wrappers/AvailableArchiveWrapper.swift new file mode 100644 index 00000000..5f75493b --- /dev/null +++ b/ios/Wrappers/AvailableArchiveWrapper.swift @@ -0,0 +1,23 @@ +import Foundation +import XMTP + +struct AvailableArchiveWrapper { + let pin: String + + init(_ archive: XMTP.AvailableArchive) { + self.pin = archive.pin + } + + func toJsonObject() -> [String: Any] { + ["pin": pin] + } + + static func encodeList(_ archives: [XMTP.AvailableArchive]) throws -> String { + let wrappers = archives.map { AvailableArchiveWrapper($0).toJsonObject() } + let data = try JSONSerialization.data(withJSONObject: wrappers) + guard let result = String(data: data, encoding: .utf8) else { + throw WrapperError.encodeError("could not encode AvailableArchive list") + } + return result + } +} diff --git a/ios/XMTPModule.swift b/ios/XMTPModule.swift index 63dd6287..bd7013bd 100644 --- a/ios/XMTPModule.swift +++ b/ios/XMTPModule.swift @@ -2998,6 +2998,59 @@ public class XMTPModule: Module { return try ArchiveMetadataWrapper.encode(metadata) } + AsyncFunction("sendSyncArchive") { + ( + installationId: String, pin: String, serverUrl: String?, + startNs: Int64?, endNs: Int64?, archiveElements: [String]?, + excludeDisappearingMessages: Bool? + ) in + guard + let client = await clientsManager.getClient(key: installationId) + else { + throw Error.noClient + } + let elements = try archiveElements?.map { try getArchiveElement($0) } ?? [.messages, .consent] + let opts = XMTP.ArchiveOptions( + startNs: startNs, + endNs: endNs, + archiveElements: elements, + excludeDisappearingMessages: excludeDisappearingMessages ?? false + ) + try await client.sendSyncArchive(opts: opts, serverUrl: serverUrl, pin: pin) + } + + AsyncFunction("processSyncArchive") { + (installationId: String, archivePin: String?) in + guard + let client = await clientsManager.getClient(key: installationId) + else { + throw Error.noClient + } + try await client.processSyncArchive(archivePin: archivePin) + } + + AsyncFunction("listAvailableArchives") { + (installationId: String, daysCutoff: Int64) -> String in + guard + let client = await clientsManager.getClient(key: installationId) + else { + throw Error.noClient + } + let archives = try client.listAvailableArchives(daysCutoff: daysCutoff) + return try AvailableArchiveWrapper.encodeList(archives) + } + + AsyncFunction("syncAllDeviceSyncGroups") { + (installationId: String) -> String in + guard + let client = await clientsManager.getClient(key: installationId) + else { + throw Error.noClient + } + let summary = try await client.syncAllDeviceSyncGroups() + return try GroupSyncSummaryWrapper(summary).toJson() + } + AsyncFunction("leaveGroup") { ( installationId: String, groupId: String @@ -3317,7 +3370,6 @@ public class XMTPModule: Module { preAuthenticateToInboxCallback: preAuthenticateToInboxCallback, dbEncryptionKey: dbEncryptionKey, dbDirectory: authOptions.dbDirectory, - historySyncUrl: authOptions.historySyncUrl, deviceSyncEnabled: authOptions.deviceSyncEnabled, debugEventsEnabled: authOptions.debugEventsEnabled, forkRecoveryOptions: authOptions.forkRecoveryOptions diff --git a/ios/XMTPReactNative.podspec b/ios/XMTPReactNative.podspec index 4f1edf5a..4d7d6af5 100644 --- a/ios/XMTPReactNative.podspec +++ b/ios/XMTPReactNative.podspec @@ -26,7 +26,7 @@ Pod::Spec.new do |s| s.source_files = "**/*.{h,m,swift}" s.dependency "MessagePacker" - s.dependency "XMTP", "= 4.9.0" + s.dependency "XMTP", "= 4.10.0-rc2" s.dependency 'CSecp256k1', '~> 0.2' s.dependency "SQLCipher", "= 4.5.7" end diff --git a/package.json b/package.json index f93ee259..3096e0b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xmtp/react-native-sdk", - "version": "5.6.0", + "version": "5.7.0", "description": "Wraps for native xmtp sdks for react native", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 4dea3c4f..dec732a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { content, keystore } from '@xmtp/proto' import { EventEmitter, NativeModulesProxy } from 'expo-modules-core' import XMTPModule from './XMTPModule' -import { ArchiveMetadata } from './lib/ArchiveOptions' +import { ArchiveMetadata, AvailableArchive } from './lib/ArchiveOptions' import { Address, Client, @@ -137,7 +137,6 @@ export async function createRandom( dbEncryptionKey: Uint8Array, hasPreAuthenticateToInboxCallback?: boolean | undefined, dbDirectory?: string | undefined, - historySyncUrl?: string | undefined, customLocalHost?: string | undefined, deviceSyncEnabled?: boolean | undefined, debugEventsEnabled?: boolean | undefined, @@ -148,7 +147,6 @@ export async function createRandom( const authParams: AuthParams = { environment, dbDirectory, - historySyncUrl, customLocalHost, deviceSyncEnabled, debugEventsEnabled, @@ -172,7 +170,6 @@ export async function create( dbEncryptionKey: Uint8Array, hasPreAuthenticateToInboxCallback?: boolean | undefined, dbDirectory?: string | undefined, - historySyncUrl?: string | undefined, signerType?: SignerType | undefined, chainId?: number | undefined, blockNumber?: number | undefined, @@ -186,7 +183,6 @@ export async function create( const authParams: AuthParams = { environment, dbDirectory, - historySyncUrl, customLocalHost, deviceSyncEnabled, debugEventsEnabled, @@ -216,7 +212,6 @@ export async function build( environment: 'local' | 'dev' | 'production', dbEncryptionKey: Uint8Array, dbDirectory?: string | undefined, - historySyncUrl?: string | undefined, inboxId?: InboxId | undefined, customLocalHost?: string | undefined, deviceSyncEnabled?: boolean | undefined, @@ -228,7 +223,6 @@ export async function build( const authParams: AuthParams = { environment, dbDirectory, - historySyncUrl, customLocalHost, deviceSyncEnabled, debugEventsEnabled, @@ -252,7 +246,6 @@ export async function ffiCreateClient( environment: 'local' | 'dev' | 'production', dbEncryptionKey: Uint8Array, dbDirectory?: string | undefined, - historySyncUrl?: string | undefined, customLocalHost?: string | undefined, deviceSyncEnabled?: boolean | undefined, debugEventsEnabled?: boolean | undefined, @@ -263,7 +256,6 @@ export async function ffiCreateClient( const authParams: AuthParams = { environment, dbDirectory, - historySyncUrl, customLocalHost, deviceSyncEnabled, debugEventsEnabled, @@ -1342,11 +1334,56 @@ export async function sendSyncRequest( return await XMTPModule.sendSyncRequest(installationId) } +export async function sendSyncArchive( + installationId: InstallationId, + pin: string, + serverUrl?: string | undefined, + startNs?: number | undefined, + endNs?: number | undefined, + archiveElements?: string[] | undefined, + excludeDisappearingMessages?: boolean | undefined +): Promise { + return await XMTPModule.sendSyncArchive( + installationId, + pin, + serverUrl, + startNs, + endNs, + archiveElements, + excludeDisappearingMessages + ) +} + +export async function processSyncArchive( + installationId: InstallationId, + archivePin?: string | undefined +): Promise { + return await XMTPModule.processSyncArchive(installationId, archivePin) +} + +export async function listAvailableArchives( + installationId: InstallationId, + daysCutoff: number +): Promise { + const json = await XMTPModule.listAvailableArchives( + installationId, + daysCutoff + ) + return JSON.parse(json) as AvailableArchive[] +} + export interface GroupSyncSummary { numEligible: number numSynced: number } +export async function syncAllDeviceSyncGroups( + installationId: InstallationId +): Promise { + const json = await XMTPModule.syncAllDeviceSyncGroups(installationId) + return JSON.parse(json) as GroupSyncSummary +} + export async function syncAllConversations( installationId: InstallationId, consentStates?: ConsentState[] | undefined @@ -1990,7 +2027,6 @@ export const emitter = new EventEmitter(XMTPModule ?? NativeModulesProxy.XMTP) interface AuthParams { environment: string dbDirectory?: string - historySyncUrl?: string customLocalHost?: string deviceSyncEnabled?: boolean debugEventsEnabled?: boolean @@ -2042,3 +2078,4 @@ export { MessageId, MessageOrder } from './lib/types/MessagesOptions' export { DecodedMessageUnion } from './lib/types/DecodedMessageUnion' export { DisappearingMessageSettings } from './lib/DisappearingMessageSettings' export { PublicIdentity } from './lib/PublicIdentity' +export type { AvailableArchive } from './lib/ArchiveOptions' diff --git a/src/lib/ArchiveOptions.ts b/src/lib/ArchiveOptions.ts index 01266102..d61ebcff 100644 --- a/src/lib/ArchiveOptions.ts +++ b/src/lib/ArchiveOptions.ts @@ -1,5 +1,17 @@ export type ArchiveElement = 'messages' | 'consent' +/** + * Represents an available sync archive that can be processed (e.g. from another device). + * Native iOS may return only `pin`; Android may also include `metadata` and `sentByInstallation`. + */ +export interface AvailableArchive { + pin: string + /** JSON-encoded archive metadata, when provided by the native layer */ + metadata?: string + /** Base64-encoded installation id that sent the archive, when provided */ + sentByInstallation?: string +} + export class ArchiveOptions { startNs?: number endNs?: number diff --git a/src/lib/Client.ts b/src/lib/Client.ts index de879a5e..e2bd794b 100644 --- a/src/lib/Client.ts +++ b/src/lib/Client.ts @@ -103,7 +103,6 @@ export class Client< options.dbEncryptionKey, Boolean(authInboxSubscription), options.dbDirectory, - options.historySyncUrl, options.customLocalHost, options.deviceSyncEnabled, options.debugEventsEnabled, @@ -191,7 +190,6 @@ export class Client< options.dbEncryptionKey, Boolean(authInboxSubscription), options.dbDirectory, - options.historySyncUrl, signingKey.signerType?.(), signingKey.getChainId?.(), signingKey.getBlockNumber?.(), @@ -234,7 +232,6 @@ export class Client< options.env, options.dbEncryptionKey, options.dbDirectory, - options.historySyncUrl, inboxId, options.customLocalHost, options.deviceSyncEnabled, @@ -283,7 +280,6 @@ export class Client< options.env, options.dbEncryptionKey, options.dbDirectory, - options.historySyncUrl, options.customLocalHost, options.deviceSyncEnabled, options.debugEventsEnabled, @@ -908,6 +904,54 @@ export class Client< return await XMTPModule.sendSyncRequest(this.installationId) } + /** + * Send a sync archive to the history sync server (e.g. for another device to process). + */ + async sendSyncArchive( + pin: string, + serverUrl?: string, + startNs?: number, + endNs?: number, + archiveElements?: string[], + excludeDisappearingMessages?: boolean + ): Promise { + return await XMTPModule.sendSyncArchive( + this.installationId, + pin, + serverUrl, + startNs, + endNs, + archiveElements, + excludeDisappearingMessages + ) + } + + /** + * Process a sync archive (e.g. one listed by listAvailableArchives or sent from another device). + */ + async processSyncArchive(archivePin?: string): Promise { + return await XMTPModule.processSyncArchive(this.installationId, archivePin) + } + + /** + * List available sync archives (e.g. from other devices) within the given days cutoff. + */ + async listAvailableArchives( + daysCutoff: number + ): Promise { + return await XMTPModule.listAvailableArchives( + this.installationId, + daysCutoff + ) + } + + /** + * Sync all device sync groups for this client. + */ + async syncAllDeviceSyncGroups(): Promise { + return await XMTPModule.syncAllDeviceSyncGroups(this.installationId) + } + /** * Make a request for your inbox state. * @@ -1237,10 +1281,6 @@ export type ClientOptions = { * OPTIONAL specify an appVersion */ appVersion?: string - /** - * OPTIONAL specify a url to sync message history from - */ - historySyncUrl?: string /** * OPTIONAL specify a custom local host for testing on physical devices for example `localhost` */