diff --git a/README.md b/README.md index 6ff2146ce..53977260a 100644 --- a/README.md +++ b/README.md @@ -437,3 +437,7 @@ 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). + +## Enabling group chat + +Coming soon... \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index ddbac2592..107248817 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -98,8 +98,19 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" - implementation "org.xmtp:android:0.7.6" 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" + implementation "org.xmtp:android:0.7.9" + // xmtp-android local testing setup below (comment org.xmtp:android above) + // implementation files('/xmtp-android/library/build/outputs/aar/library-debug.aar') + // implementation 'com.google.crypto.tink:tink-android:1.7.0' + // implementation 'io.grpc:grpc-kotlin-stub:1.3.0' + // implementation 'io.grpc:grpc-okhttp:1.51.1' + // implementation 'io.grpc:grpc-protobuf-lite:1.51.0' + // implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' + // implementation 'org.web3j:crypto:5.0.0' + // implementation "net.java.dev.jna:jna:5.13.0@aar" + // implementation 'com.google.protobuf:protobuf-kotlin-lite:3.22.3' + // implementation 'org.xmtp:proto-kotlin:3.40.1' } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 4e79d8979..69d238dad 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -1,5 +1,6 @@ package expo.modules.xmtpreactnativesdk +import android.content.Context import android.net.Uri import android.util.Base64 import android.util.Base64.NO_WRAP @@ -7,6 +8,7 @@ import android.util.Log import androidx.core.net.toUri import com.google.gson.JsonParser import com.google.protobuf.kotlin.toByteString +import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.xmtpreactnativesdk.wrappers.ConsentWrapper @@ -16,17 +18,20 @@ import expo.modules.xmtpreactnativesdk.wrappers.ConversationWrapper import expo.modules.xmtpreactnativesdk.wrappers.DecodedMessageWrapper import expo.modules.xmtpreactnativesdk.wrappers.DecryptedLocalAttachment import expo.modules.xmtpreactnativesdk.wrappers.EncryptedLocalAttachment +import expo.modules.xmtpreactnativesdk.wrappers.GroupWrapper import expo.modules.xmtpreactnativesdk.wrappers.PreparedLocalMessage import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import org.json.JSONObject import org.xmtp.android.library.Client import org.xmtp.android.library.ClientOptions import org.xmtp.android.library.Conversation +import org.xmtp.android.library.Group import org.xmtp.android.library.PreEventCallback import org.xmtp.android.library.PreparedMessage import org.xmtp.android.library.SendOptions @@ -97,7 +102,15 @@ fun Conversation.cacheKey(clientAddress: String): String { return "${clientAddress}:${topic}" } +fun Group.cacheKey(clientAddress: String): String { + return "${clientAddress}:${id}" +} + class XMTPModule : Module() { + + val context: Context + get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + private fun apiEnvironments(env: String, appVersion: String?): ClientOptions.Api { return when (env) { "local" -> ClientOptions.Api( @@ -125,6 +138,7 @@ class XMTPModule : Module() { private var signer: ReactNativeSigner? = null private val isDebugEnabled = BuildConfig.DEBUG // TODO: consider making this configurable private val conversations: MutableMap = mutableMapOf() + private val groups: MutableMap = mutableMapOf() private val subscriptions: MutableMap = mutableMapOf() private var preEnableIdentityCallbackDeferred: CompletableDeferred? = null private var preCreateIdentityCallbackDeferred: CompletableDeferred? = null @@ -150,8 +164,9 @@ class XMTPModule : Module() { // // Auth functions // - AsyncFunction("auth") { address: String, environment: String, appVersion: String?, hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean? -> + AsyncFunction("auth") { address: String, environment: String, appVersion: String?, hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean?, enableAlphaMls: Boolean? -> logV("auth") + requireLocalEnvForAlphaMLS(enableAlphaMls, environment) val reactSigner = ReactNativeSigner(module = this@XMTPModule, address = address) signer = reactSigner @@ -163,10 +178,14 @@ class XMTPModule : Module() { preCreateIdentityCallback.takeIf { hasCreateIdentityCallback == true } val preEnableIdentityCallback: PreEventCallback? = preEnableIdentityCallback.takeIf { hasEnableIdentityCallback == true } + val context = if (enableAlphaMls == true) context else null + val options = ClientOptions( api = apiEnvironments(environment, appVersion), preCreateIdentityCallback = preCreateIdentityCallback, - preEnableIdentityCallback = preEnableIdentityCallback + preEnableIdentityCallback = preEnableIdentityCallback, + enableAlphaMls = enableAlphaMls == true, + appContext = context ) clients[address] = Client().create(account = reactSigner, options = options) ContentJson.Companion @@ -180,8 +199,9 @@ class XMTPModule : Module() { } // Generate a random wallet and set the client to that - AsyncFunction("createRandom") { environment: String, appVersion: String?, hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean? -> + AsyncFunction("createRandom") { environment: String, appVersion: String?, hasCreateIdentityCallback: Boolean?, hasEnableIdentityCallback: Boolean?, enableAlphaMls: Boolean? -> logV("createRandom") + requireLocalEnvForAlphaMLS(enableAlphaMls, environment) val privateKey = PrivateKeyBuilder() if (hasCreateIdentityCallback == true) @@ -192,11 +212,14 @@ class XMTPModule : Module() { preCreateIdentityCallback.takeIf { hasCreateIdentityCallback == true } val preEnableIdentityCallback: PreEventCallback? = preEnableIdentityCallback.takeIf { hasEnableIdentityCallback == true } + val context = if (enableAlphaMls == true) context else null val options = ClientOptions( api = apiEnvironments(environment, appVersion), preCreateIdentityCallback = preCreateIdentityCallback, - preEnableIdentityCallback = preEnableIdentityCallback + preEnableIdentityCallback = preEnableIdentityCallback, + enableAlphaMls = enableAlphaMls == true, + appContext = context ) val randomClient = Client().create(account = privateKey, options = options) ContentJson.Companion @@ -204,10 +227,16 @@ class XMTPModule : Module() { randomClient.address } - AsyncFunction("createFromKeyBundle") { keyBundle: String, environment: String, appVersion: String? -> + AsyncFunction("createFromKeyBundle") { keyBundle: String, environment: String, appVersion: String?, enableAlphaMls: Boolean? -> + logV("createFromKeyBundle") + requireLocalEnvForAlphaMLS(enableAlphaMls, environment) try { - logV("createFromKeyBundle") - val options = ClientOptions(api = apiEnvironments(environment, appVersion)) + val context = if (enableAlphaMls == true) context else null + val options = ClientOptions( + api = apiEnvironments(environment, appVersion), + enableAlphaMls = enableAlphaMls == true, + appContext = context + ) val bundle = PrivateKeyOuterClass.PrivateKeyBundle.parseFrom( Base64.decode( @@ -352,6 +381,16 @@ class XMTPModule : Module() { } } + AsyncFunction("listGroups") { clientAddress: String -> + logV("listGroups") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val groupList = client.conversations.listGroups() + groupList.map { group -> + groups[group.cacheKey(clientAddress)] = group + GroupWrapper.encode(client, group) + } + } + AsyncFunction("loadMessages") { clientAddress: String, topic: String, limit: Int?, before: Long?, after: Long?, direction: String? -> logV("loadMessages") val conversation = @@ -373,6 +412,16 @@ class XMTPModule : Module() { .map { DecodedMessageWrapper.encode(it) } } + AsyncFunction("groupMessages") { clientAddress: String, id: String -> + logV("groupMessages") + val client = clients[clientAddress] ?: throw XMTPException("No client") + if (client.libXMTPClient == null) { + throw XMTPException("Create client with enableAlphaMLS true in order to synch groups") + } + val group = findGroup(clientAddress, id) + group?.decryptedMessages()?.map { DecodedMessageWrapper.encode(it) } + } + AsyncFunction("loadBatchMessages") { clientAddress: String, topics: List -> logV("loadBatchMessages") val client = clients[clientAddress] ?: throw XMTPException("No client") @@ -433,6 +482,21 @@ class XMTPModule : Module() { ) } + AsyncFunction("sendMessageToGroup") { clientAddress: String, idString: String, contentJson: String -> + logV("sendMessageToGroup") + val group = + findGroup( + clientAddress = clientAddress, + idString = idString + ) + ?: throw XMTPException("no group found for $idString") + val sending = ContentJson.fromJson(contentJson) + group.send( + content = sending.content, + options = SendOptions(contentType = sending.type) + ) + } + AsyncFunction("prepareMessage") { clientAddress: String, conversationTopic: String, contentJson: String -> logV("prepareMessage") val conversation = @@ -531,6 +595,47 @@ class XMTPModule : Module() { ConversationWrapper.encode(client, conversation) } + AsyncFunction("createGroup") { clientAddress: String, peerAddresses: List -> + logV("createGroup") + val client = clients[clientAddress] ?: throw XMTPException("No client") + if (client.libXMTPClient == null) { + throw XMTPException("Create client with enableAlphaMLS true in order to create a group") + } + val group = client.conversations.newGroup(peerAddresses) + logV("id after creating group: " + Base64.encodeToString(group.id, NO_WRAP)) + val encodedGroup = GroupWrapper.encode(client, group) + return@AsyncFunction encodedGroup + } + + AsyncFunction("listMemberAddresses") { clientAddress: String, groupId: String -> + logV("listMembers") + val client = clients[clientAddress] ?: throw XMTPException("No client") + if (client.libXMTPClient == null) { + throw XMTPException("Create client with enableAlphaMLS true in order to create a group") + } + val group = findGroup(clientAddress, groupId) + return@AsyncFunction group?.memberAddresses() + } + + AsyncFunction("syncGroups") { clientAddress: String -> + logV("syncGroups") + val client = clients[clientAddress] ?: throw XMTPException("No client") + if (client.libXMTPClient == null) { + throw XMTPException("Create client with enableAlphaMLS true in order to synch groups") + } + runBlocking { client.conversations.syncGroups() } + } + + AsyncFunction("syncGroup") { clientAddress: String, id: String -> + logV("syncGroup") + val client = clients[clientAddress] ?: throw XMTPException("No client") + if (client.libXMTPClient == null) { + throw XMTPException("Create client with enableAlphaMLS true in order to synch groups") + } + val group = findGroup(clientAddress, id) + runBlocking { group?.sync() } + } + Function("subscribeToConversations") { clientAddress: String -> logV("subscribeToConversations") subscribeToConversations(clientAddress = clientAddress) @@ -673,6 +778,27 @@ class XMTPModule : Module() { return null } + private fun findGroup( + clientAddress: String, + idString: String, + ): Group? { + val client = clients[clientAddress] ?: throw XMTPException("No client") + + val cacheKey = "${clientAddress}:${idString}" + val cacheGroup = groups[cacheKey] + if (cacheGroup != null) { + return cacheGroup + } else { + val group = client.conversations.listGroups() + .firstOrNull { Base64.encodeToString(it.id, NO_WRAP) == idString } + if (group != null) { + groups[group.cacheKey(clientAddress)] = group + return group + } + } + return null + } + private fun subscribeToConversations(clientAddress: String) { val client = clients[clientAddress] ?: throw XMTPException("No client") @@ -780,6 +906,12 @@ class XMTPModule : Module() { preCreateIdentityCallbackDeferred?.await() preCreateIdentityCallbackDeferred = null } + + private fun requireLocalEnvForAlphaMLS(enableAlphaMls: Boolean?, environment: String) { + if (enableAlphaMls == true && environment != "local") { + throw XMTPException("Environment must be \"local\" to enable alpha MLS") + } + } } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt new file mode 100644 index 000000000..e586bd63c --- /dev/null +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/GroupWrapper.kt @@ -0,0 +1,28 @@ +package expo.modules.xmtpreactnativesdk.wrappers + +import android.util.Base64 +import android.util.Base64.NO_WRAP +import com.google.gson.GsonBuilder +import org.xmtp.android.library.Client +import org.xmtp.android.library.Group + +class GroupWrapper { + + companion object { + private fun encodeToObj(client: Client, group: Group, idString: String): Map { + return mapOf( + "clientAddress" to client.address, + "id" to idString, + "createdAt" to group.createdAt.time, + "peerAddresses" to group.memberAddresses(), + + ) + } + + fun encode(client: Client, group: Group): String { + val gson = GsonBuilder().create() + val obj = encodeToObj(client, group, Base64.encodeToString(group.id, NO_WRAP)) + return gson.toJson(obj) + } + } +} \ No newline at end of file diff --git a/example/src/LaunchScreen.tsx b/example/src/LaunchScreen.tsx index 3bf8f37bb..730421797 100644 --- a/example/src/LaunchScreen.tsx +++ b/example/src/LaunchScreen.tsx @@ -138,6 +138,24 @@ export default function LaunchScreen({ }} /> + +