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
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,39 @@ class ClientTest {
}
}

@Test
fun testClientD14NStagingCanBeCreatedWithBundle() {
val key = SecureRandom().generateSeed(32)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val fakeWallet = PrivateKeyBuilder()
val options = ClientOptions(
ClientOptions.Api(XMTPEnvironment.DEV, true, gatewayUrl = "https://payer.testnet-staging.xmtp.network:443"),
appContext = context,
dbEncryptionKey = key
)
val client = runBlocking {
Client.create(account = fakeWallet, options = options)
}

val clientIdentity = fakeWallet.publicIdentity
runBlocking {
client.canMessage(listOf(clientIdentity))[clientIdentity.identifier]?.let { assert(it) }
}

val fromBundle = runBlocking {
Client.build(clientIdentity, options = options)
}
assertEquals(client.inboxId, fromBundle.inboxId)

runBlocking {
fromBundle.canMessage(listOf(clientIdentity))[clientIdentity.identifier]?.let {
assert(
it
)
}
}
}

@Test
fun testCanBeBuiltOffline() {
val fixtures = fixtures()
Expand Down Expand Up @@ -107,7 +140,34 @@ class ClientTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val fakeWallet = PrivateKeyBuilder()
val options = ClientOptions(
ClientOptions.Api(XMTPEnvironment.LOCAL, false, "Testing/0.0.0"),
ClientOptions.Api(XMTPEnvironment.LOCAL, true, "Testing/0.0.0"),
appContext = context,
dbEncryptionKey = key
)
val clientIdentity = fakeWallet.publicIdentity

val inboxId = runBlocking { Client.getOrCreateInboxId(options.api, clientIdentity) }
val client = runBlocking {
Client.create(
account = fakeWallet,
options = options
)
}
runBlocking {
client.canMessage(listOf(clientIdentity))[clientIdentity.identifier]?.let { assert(it) }
}
assert(client.installationId.isNotEmpty())
assertEquals(inboxId, client.inboxId)
assertEquals(fakeWallet.publicIdentity.identifier, client.publicIdentity.identifier)
}

@Test
fun testCreatesAClientD14NStaging() {
val key = SecureRandom().generateSeed(32)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val fakeWallet = PrivateKeyBuilder()
val options = ClientOptions(
ClientOptions.Api(XMTPEnvironment.DEV, true, "Testing/0.0.0", gatewayUrl = "https://payer.testnet-staging.xmtp.network:443"),
appContext = context,
dbEncryptionKey = key
)
Expand Down
6 changes: 3 additions & 3 deletions library/src/main/java/libxmtp-version.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
Version: fe6e305ac
Branch: release/v1.5
Date: 2025-10-10 23:55:41 +0000
Version: c0462383b
Branch: d14z-client-prerelease
Date: 2025-10-16 22:58:47 +0000
27 changes: 22 additions & 5 deletions library/src/main/java/org/xmtp/android/library/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ data class ClientOptions(
data class Api(
val env: XMTPEnvironment = XMTPEnvironment.DEV,
val isSecure: Boolean = true,
val appVersion: String? = null
val appVersion: String? = null,
val gatewayUrl: String? = null
)
}

Expand Down Expand Up @@ -144,8 +145,24 @@ class Client(
return deletedCount
}

/**
* Creates a unique cache key for API client instances based on all configuration parameters.
*
* The cache key incorporates all parameters that affect the backend connection to ensure
* that different API configurations don't share the same cached client instance.
*
* @param api The API configuration containing connection parameters
* @return A pipe-delimited string containing: env_url|gateway_url|is_secure|app_version
*
* Note: Handles nullable values (gatewayUrl, appVersion) by converting them to "null" strings,
* ensuring consistent cache key generation even when optional parameters are not provided.
*/
private fun createCacheKey(api: ClientOptions.Api): String {
return "${api.env.getUrl()}|${api.gatewayUrl ?: "null"}|${api.isSecure}|${api.appVersion ?: "null"}"
}

suspend fun connectToApiBackend(api: ClientOptions.Api): XmtpApiClient {
val cacheKey = api.env.getUrl()
val cacheKey = createCacheKey(api)
return cacheLock.withLock {
val cached = apiClientCache[cacheKey]

Expand All @@ -154,14 +171,14 @@ class Client(
}

// If not cached or not connected, create a fresh client
val newClient = connectToBackend(api.env.getUrl(), api.isSecure, api.appVersion)
val newClient = connectToBackend(api.env.getUrl(), api.gatewayUrl, api.isSecure, api.appVersion)
apiClientCache[cacheKey] = newClient
return@withLock newClient
}
}

suspend fun connectToSyncApiBackend(api: ClientOptions.Api): XmtpApiClient {
val cacheKey = api.env.getUrl()
val cacheKey = createCacheKey(api)
return syncCacheLock.withLock {
val cached = syncApiClientCache[cacheKey]

Expand All @@ -170,7 +187,7 @@ class Client(
}

// If not cached or not connected, create a fresh client
val newClient = connectToBackend(api.env.getUrl(), api.isSecure, api.appVersion)
val newClient = connectToBackend(api.env.getUrl(), api.gatewayUrl, api.isSecure, api.appVersion)
syncApiClientCache[cacheKey] = newClient
return@withLock newClient
}
Expand Down
18 changes: 12 additions & 6 deletions library/src/main/java/org/xmtp/android/library/Conversation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,13 @@ sealed class Conversation {
}
}

suspend fun prepareMessage(encodedContent: EncodedContent): String = withContext(Dispatchers.IO) {
suspend fun prepareMessage(
encodedContent: EncodedContent,
opts: MessageVisibilityOptions = MessageVisibilityOptions(shouldPush = true)
): String = withContext(Dispatchers.IO) {
when (this@Conversation) {
is Group -> group.prepareMessage(encodedContent)
is Dm -> dm.prepareMessage(encodedContent)
is Group -> group.prepareMessage(encodedContent, opts)
is Dm -> dm.prepareMessage(encodedContent, opts)
}
}

Expand All @@ -174,10 +177,13 @@ sealed class Conversation {
}
}

suspend fun send(encodedContent: EncodedContent): String = withContext(Dispatchers.IO) {
suspend fun send(
encodedContent: EncodedContent,
opts: MessageVisibilityOptions = MessageVisibilityOptions(shouldPush = true)
): String = withContext(Dispatchers.IO) {
when (this@Conversation) {
is Group -> group.send(encodedContent)
is Dm -> dm.send(encodedContent)
is Group -> group.send(encodedContent, opts)
is Dm -> dm.send(encodedContent, opts)
}
}

Expand Down
39 changes: 27 additions & 12 deletions library/src/main/java/org/xmtp/android/library/Dm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,27 +93,35 @@ class Dm(
}

suspend fun send(text: String): String = withContext(Dispatchers.IO) {
send(encodeContent(content = text, options = null))
val (encodedContent, opts) = encodeContent(content = text, options = null)
send(encodedContent, opts)
}

suspend fun <T> send(content: T, options: SendOptions? = null): String = withContext(Dispatchers.IO) {
val preparedMessage = encodeContent(content = content, options = options)
send(preparedMessage)
val (encodedContent, opts) = encodeContent(content = content, options = options)
send(encodedContent, opts)
}

suspend fun send(encodedContent: EncodedContent): String = withContext(Dispatchers.IO) {
val messageId = libXMTPGroup.send(contentBytes = encodedContent.toByteArray())
suspend fun send(
encodedContent: EncodedContent,
opts: MessageVisibilityOptions = MessageVisibilityOptions(shouldPush = true),
): String = withContext(Dispatchers.IO) {
val messageId = libXMTPGroup.send(
contentBytes = encodedContent.toByteArray(),
opts.toFfi()
)
messageId.toHex()
}

fun <T> encodeContent(content: T, options: SendOptions?): EncodedContent {
fun <T> encodeContent(content: T, options: SendOptions?): Pair<EncodedContent, MessageVisibilityOptions> {
val codec = Client.codecRegistry.find(options?.contentType)
fun <Codec : ContentCodec<T>> encode(codec: Codec, content: T): EncodedContent {
return codec.encode(content)
}
try {
@Suppress("UNCHECKED_CAST")
var encoded = encode(codec as ContentCodec<T>, content)
val typedCodec = codec as ContentCodec<T>
var encoded = encode(typedCodec, content)
val fallback = codec.fallback(content)
if (!fallback.isNullOrBlank()) {
encoded = encoded.toBuilder().also {
Expand All @@ -124,19 +132,26 @@ class Dm(
if (compression != null) {
encoded = encoded.compress(compression)
}
return encoded
val sendOpts = MessageVisibilityOptions(shouldPush = typedCodec.shouldPush(content))
return Pair(encoded, sendOpts)
} catch (e: Exception) {
throw XMTPException("Codec type is not registered")
}
}

suspend fun prepareMessage(encodedContent: EncodedContent): String = withContext(Dispatchers.IO) {
libXMTPGroup.sendOptimistic(encodedContent.toByteArray()).toHex()
suspend fun prepareMessage(
encodedContent: EncodedContent,
opts: MessageVisibilityOptions = MessageVisibilityOptions(shouldPush = true),
): String = withContext(Dispatchers.IO) {
libXMTPGroup.sendOptimistic(
encodedContent.toByteArray(),
opts.toFfi()
).toHex()
}

suspend fun <T> prepareMessage(content: T, options: SendOptions? = null): String = withContext(Dispatchers.IO) {
val encodeContent = encodeContent(content = content, options = options)
libXMTPGroup.sendOptimistic(encodeContent.toByteArray()).toHex()
val (encodedContent, opts) = encodeContent(content = content, options = options)
libXMTPGroup.sendOptimistic(encodedContent.toByteArray(), opts.toFfi()).toHex()
}

suspend fun publishMessages() = withContext(Dispatchers.IO) {
Expand Down
44 changes: 30 additions & 14 deletions library/src/main/java/org/xmtp/android/library/Group.kt
Original file line number Diff line number Diff line change
Expand Up @@ -134,27 +134,36 @@ class Group(
}

suspend fun send(text: String): String = withContext(Dispatchers.IO) {
send(encodeContent(content = text, options = null))
val (encodedContent, opts) = encodeContent(content = text, options = null)
send(encodedContent, opts)
}

suspend fun <T> send(content: T, options: SendOptions? = null): String = withContext(Dispatchers.IO) {
val preparedMessage = encodeContent(content = content, options = options)
send(preparedMessage)
}

suspend fun send(encodedContent: EncodedContent): String = withContext(Dispatchers.IO) {
val messageId = libXMTPGroup.send(contentBytes = encodedContent.toByteArray())
val (encodedContent, opts) = encodeContent(content = content, options = options)
send(encodedContent, opts)
}

suspend fun send(
encodedContent: EncodedContent,
opts: MessageVisibilityOptions = MessageVisibilityOptions(shouldPush = true),
): String = withContext(Dispatchers.IO) {
val messageId =
libXMTPGroup.send(
contentBytes = encodedContent.toByteArray(),
opts = opts.toFfi(),
)
messageId.toHex()
}

fun <T> encodeContent(content: T, options: SendOptions?): EncodedContent {
fun <T> encodeContent(content: T, options: SendOptions?): Pair<EncodedContent, MessageVisibilityOptions> {
val codec = Client.codecRegistry.find(options?.contentType)
fun <Codec : ContentCodec<T>> encode(codec: Codec, content: T): EncodedContent {
return codec.encode(content)
}
try {
@Suppress("UNCHECKED_CAST")
var encoded = encode(codec as ContentCodec<T>, content)
val typedCodec = codec as ContentCodec<T>
var encoded = encode(typedCodec, content)
val fallback = codec.fallback(content)
if (!fallback.isNullOrBlank()) {
encoded = encoded.toBuilder().also {
Expand All @@ -165,19 +174,26 @@ class Group(
if (compression != null) {
encoded = encoded.compress(compression)
}
return encoded
val sendOpts = MessageVisibilityOptions(shouldPush = typedCodec.shouldPush(content))
return Pair(encoded, sendOpts)
} catch (e: Exception) {
throw XMTPException("Codec type is not registered")
}
}

suspend fun prepareMessage(encodedContent: EncodedContent): String = withContext(Dispatchers.IO) {
libXMTPGroup.sendOptimistic(encodedContent.toByteArray()).toHex()
suspend fun prepareMessage(
encodedContent: EncodedContent,
opts: MessageVisibilityOptions = MessageVisibilityOptions(shouldPush = true),
): String = withContext(Dispatchers.IO) {
libXMTPGroup.sendOptimistic(
encodedContent.toByteArray(),
opts.toFfi()
).toHex()
}

suspend fun <T> prepareMessage(content: T, options: SendOptions? = null): String = withContext(Dispatchers.IO) {
val encodeContent = encodeContent(content = content, options = options)
libXMTPGroup.sendOptimistic(encodeContent.toByteArray()).toHex()
val (encodedContent, opts) = encodeContent(content = content, options = options)
libXMTPGroup.sendOptimistic(encodedContent.toByteArray(), opts.toFfi()).toHex()
}

suspend fun publishMessages() = withContext(Dispatchers.IO) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
package org.xmtp.android.library

import org.xmtp.proto.message.contents.Content
import uniffi.xmtpv3.FfiSendMessageOpts

data class SendOptions(
var compression: EncodedContentCompression? = null,
var contentType: Content.ContentTypeId? = null,
@Deprecated("This option is no longer supported and does nothing")
var ephemeral: Boolean = false
)

data class MessageVisibilityOptions(
val shouldPush: Boolean,
) {
fun toFfi(): FfiSendMessageOpts = FfiSendMessageOpts(shouldPush = shouldPush)
}
Loading
Loading