Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -243,7 +238,6 @@ class XMTPModule : Module() {
appContext = context,
dbEncryptionKey = encryptionKeyBytes,
dbDirectory = authOptions.dbDirectory,
historySyncUrl = historySyncUrl,
deviceSyncEnabled = authOptions.deviceSyncEnabled,
forkRecoveryOptions = authOptions.forkRecoveryOptions
)
Expand Down Expand Up @@ -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<String>?, 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)
}
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?> {
return if (metadata != null) {
encodeToObj(metadata)
} else {
// Create a default metadata object when null
mapOf(
"archiveVersion" to 0u,
"elements" to listOf("messages", "consent"),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium wrappers/ArchiveMetadataWrapper.kt:42

encodeToMap returns "messages" (plural) for the null-metadata default, but getArchiveElementString returns "message" (singular) for ArchiveElement.MESSAGES and the catch block fallback also uses "message". Downstream consumers expecting consistent element names receive "messages" when metadata is null but "message" when encoding succeeds or fails.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ArchiveMetadataWrapper.kt around line 42:

`encodeToMap` returns `"messages"` (plural) for the null-metadata default, but `getArchiveElementString` returns `"message"` (singular) for `ArchiveElement.MESSAGES` and the catch block fallback also uses `"message"`. Downstream consumers expecting consistent element names receive `"messages"` when metadata is null but `"message"` when encoding succeeds or fails.

Evidence trail:
android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/ArchiveMetadataWrapper.kt at REVIEWED_COMMIT:
- Line 42: `"elements" to listOf("messages", "consent")` (plural "messages")
- Line 28: `ArchiveElement.MESSAGES -> "message"` (singular "message")
- Lines 18-20: catch block returns `listOf("message", "consent")` (singular "message")

Expand All @@ -45,7 +45,10 @@ class ArchiveMetadataWrapper {
"endNs" to null
)
}
return gson.toJson(obj)
}

fun encode(metadata: ArchiveMetadata?): String {
return gson.toJson(encodeToMap(metadata))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any?> = mapOf(
"pin" to archive.pin,
"metadata" to ArchiveMetadataWrapper.encodeToMap(archive.metadata),
"sentByInstallation" to Base64.encodeToString(archive.sentByInstallation, Base64.NO_WRAP),
)

fun encodeList(archives: List<AvailableArchive>): String {
val list = archives.map { encodeToObj(it) }
return gson.toJson(list)
}
}
}
1 change: 1 addition & 0 deletions example/EXAMPLE.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
TEST_PRIVATE_KEY=INSERT_TEST_PRIVATE_KEY_HERE
THIRD_WEB_CLIENT_ID=INSERT_CLIENT_ID_HERE
GATEWAY_HOST=D14N_GATEWAY_PAYER_HOST_URL_HERE
3 changes: 2 additions & 1 deletion example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"cameraPermission": "The app accesses your camera to let you attach photos as messages."
}
],
"expo-secure-store"
"expo-secure-store",
"./plugins/withReactNativeConfigEnv.js"
],
"ios": {
"supportsTablet": true,
Expand Down
12 changes: 6 additions & 6 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -2077,7 +2077,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
Connect-Swift: 82bcc0834587bd537f17a9720f62ea9fc7d9f3a5
Connect-Swift: 8550afdb45aaea9f527d29c74f7224bf78e2bc26
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
CSecp256k1: 2a59c03e52637ded98896a33be4b2649392cb843
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions example/plugins/withReactNativeConfigEnv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Expo config plugin: injects react-native-config's dotenv.gradle into the Android app build
* so that .env is loaded into BuildConfig and Config.* works on Android (same as iOS).
* Without this, env vars work on iOS but not Android after prebuild.
*
* @see https://github.com/luggit/react-native-config#android
*/
const { withAppBuildGradle } = require('expo/config-plugins')

const DOTENV_LINE =
'apply from: project.file("../../node_modules/react-native-config/android/dotenv.gradle")'
const COMMENT =
'// Load .env into BuildConfig so react-native-config Config.* works on Android (required for env vars in JS)'

function withReactNativeConfigEnv(config) {
return withAppBuildGradle(config, (config) => {
const contents = config.modResults.contents
if (contents.includes('react-native-config/android/dotenv.gradle')) {
return config
}
const afterPlugins = contents.indexOf('apply plugin: "com.facebook.react"')
if (afterPlugins === -1) {
return config
}
const insertAt = contents.indexOf('\n', afterPlugins) + 1
Comment on lines +21 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low plugins/withReactNativeConfigEnv.js:21

If apply plugin: "com.facebook.react" is the last line without a trailing newline, indexOf('\n', afterPlugins) returns -1, so insertAt becomes 0. This prepends the comment and dotenv.gradle to the start of the file instead of after the plugin line, corrupting the gradle structure. Consider handling the -1 case to append at the end of the file when no newline is found.

-    const insertAt = contents.indexOf('\n', afterPlugins) + 1
+    const nextNewline = contents.indexOf('\n', afterPlugins)
+    const insertAt = nextNewline === -1 ? contents.length : nextNewline + 1
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file example/plugins/withReactNativeConfigEnv.js around lines 21-25:

If `apply plugin: "com.facebook.react"` is the last line without a trailing newline, `indexOf('\n', afterPlugins)` returns `-1`, so `insertAt` becomes `0`. This prepends the comment and `dotenv.gradle` to the start of the file instead of after the plugin line, corrupting the gradle structure. Consider handling the `-1` case to append at the end of the file when no newline is found.

Evidence trail:
example/plugins/withReactNativeConfigEnv.js lines 21-29 at REVIEWED_COMMIT. Line 25 shows `const insertAt = contents.indexOf('\n', afterPlugins) + 1` which returns 0 when indexOf returns -1 (no newline found). Lines 26-29 show the slicing logic that would prepend content to the start of the file when insertAt is 0.

const before = contents.slice(0, insertAt)
const after = contents.slice(insertAt)
config.modResults.contents =
before + '\n' + COMMENT + '\n' + DOTENV_LINE + '\n\n' + after
return config
})
}

module.exports = withReactNativeConfigEnv
32 changes: 31 additions & 1 deletion example/src/LaunchScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack'
// import { ConnectWallet, useSigner } from '@thirdweb-dev/react-native'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import {
Button,
ScrollView,
Expand All @@ -18,6 +18,7 @@ import { NavigationParamList } from './Navigation'
import { TestCategory } from './TestScreen'
import { supportedCodecs } from './contentTypes/contentTypes'
import { getDbEncryptionKey } from './hooks'
import { setTestEnv, type TestEnvOption } from './testEnv'

// Custom Modal Picker Component
const CustomPicker = ({
Expand Down Expand Up @@ -105,9 +106,15 @@ export default function LaunchScreen(
const [selectedTest, setSelectedTest] = useState<TestCategory>(
TestCategory.all
)
const [selectedTestEnv, setSelectedTestEnv] = useState<TestEnvOption>('local')
const [selectedNetwork, setSelectedNetwork] = useState<
'dev' | 'local' | 'production'
>('dev')

useEffect(() => {
setTestEnv(selectedTestEnv)
}, [selectedTestEnv])

// const signer = useSigner()
// const [signerAddressDisplay, setSignerAddressDisplay] = useState<string>()
const { setClient } = useXmtp()
Expand Down Expand Up @@ -170,6 +177,12 @@ export default function LaunchScreen(
label,
}))

const testEnvOptions: { id: TestEnvOption; label: string }[] = [
{ id: 'local', label: 'local' },
{ id: 'dev', label: 'dev' },
{ id: 'd14n', label: 'd14n (dev + gateway)' },
]

return (
<ScrollView>
<Text style={styles.title}>Automated Tests</Text>
Expand All @@ -182,6 +195,23 @@ export default function LaunchScreen(
placeholder="Select Test"
/>
</View>
<View style={styles.row}>
<Text style={styles.label}>Test environment:</Text>
<CustomPicker
value={
testEnvOptions.find((o) => o.id === selectedTestEnv)?.label ??
selectedTestEnv
}
onValueChange={(value) => {
const option = testEnvOptions.find((o) => o.label === value)
const env = (option?.id ?? value) as TestEnvOption
setSelectedTestEnv(env)
setTestEnv(env)
}}
options={testEnvOptions}
placeholder="Test environment"
/>
</View>
<View key="run-tests" style={{ margin: 16 }}>
<Button
title={`Run Selected Tests: ${selectedTest}`}
Expand Down
18 changes: 18 additions & 0 deletions example/src/testEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Test environment selection for createClients() when no env is passed.
* Set from LaunchScreen; read from test-utils. Default is 'local'.
* - local: XMTP local network
* - dev: XMTP dev network
* - d14n: dev network with custom gateway (uses GATEWAY_HOST from .env)
*/
export type TestEnvOption = 'local' | 'dev' | 'd14n'

let testEnv: TestEnvOption = 'local'

export function getTestEnv(): TestEnvOption {
return testEnv
}

export function setTestEnv(value: TestEnvOption): void {
testEnv = value
}
46 changes: 45 additions & 1 deletion example/src/tests/clientTests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ethers, Wallet } from 'ethers'
import Config from 'react-native-config'
import RNFS from 'react-native-fs'
import { ArchiveOptions } from 'xmtp-react-native-sdk/lib/ArchiveOptions'
import { InstallationId } from 'xmtp-react-native-sdk/lib/Client'
import { InstallationId, XMTPEnvironment } from 'xmtp-react-native-sdk/lib/Client'

Check warning on line 5 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `·InstallationId,·XMTPEnvironment·` with `⏎··InstallationId,⏎··XMTPEnvironment,⏎`

Check warning on line 5 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `·InstallationId,·XMTPEnvironment·` with `⏎··InstallationId,⏎··XMTPEnvironment,⏎`

import {
Test,
Expand Down Expand Up @@ -1330,3 +1331,46 @@

return true
})

test('can make a new client and build existing from existing db using d14n staging testnet', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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`
const directoryExists = await RNFS.exists(dbDirPath)
if (!directoryExists) {
await RNFS.mkdir(dbDirPath)
}

const options = {
env: 'dev' as XMTPEnvironment,
dbEncryptionKey: keyBytes,
dbDirectory: dbDirPath,
deviceSyncEnabled: false,
appVersion: '0.0.0',
gatewayHost: Config.GATEWAY_HOST

Check warning on line 1353 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`

Check warning on line 1353 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
}

Check warning on line 1355 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Delete `··`

Check warning on line 1355 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Delete `··`
const client = await Client.createRandom(options)

const inboxId = await Client.getOrCreateInboxId(

Check warning on line 1358 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎····client.publicIdentity,⏎····'dev'⏎··` with `client.publicIdentity,·'dev'`

Check warning on line 1358 in example/src/tests/clientTests.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎····client.publicIdentity,⏎····'dev'⏎··` with `client.publicIdentity,·'dev'`
client.publicIdentity,
'dev'
)

assert(
client.inboxId === inboxId,
`inboxIds should match but were ${client.inboxId} and ${inboxId}`
)

const clientFromBundle = await Client.build(client.publicIdentity, options)

assert(
clientFromBundle.inboxId === client.inboxId,
`inboxIds should match but were ${clientFromBundle.inboxId} and ${client.inboxId}`
)

return true
})
Loading
Loading