diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 3a25c58f4..118f06a4d 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -333,10 +333,12 @@ class XMTPModule : Module() { // Export the conversation's serialized topic data. AsyncFunction("exportConversationTopicData") Coroutine { clientAddress: String, topic: String -> - logV("exportConversationTopicData") - val conversation = findConversation(clientAddress, topic) - ?: throw XMTPException("no conversation found for $topic") - Base64.encodeToString(conversation.toTopicData().toByteArray(), NO_WRAP) + withContext(Dispatchers.IO) { + logV("exportConversationTopicData") + val conversation = findConversation(clientAddress, topic) + ?: throw XMTPException("no conversation found for $topic") + Base64.encodeToString(conversation.toTopicData().toByteArray(), NO_WRAP) + } } AsyncFunction("getHmacKeys") { clientAddress: String -> @@ -440,332 +442,374 @@ class XMTPModule : Module() { } AsyncFunction("sendEncodedContent") Coroutine { clientAddress: String, topic: String, encodedContentData: List -> - val conversation = - findConversation( - clientAddress = clientAddress, - topic = topic - ) ?: throw XMTPException("no conversation found for $topic") - - val encodedContentDataBytes = - encodedContentData.foldIndexed(ByteArray(encodedContentData.size)) { i, a, v -> - a.apply { - set( - i, - v.toByte() - ) + withContext(Dispatchers.IO) { + val conversation = + findConversation( + clientAddress = clientAddress, + topic = topic + ) ?: throw XMTPException("no conversation found for $topic") + + val encodedContentDataBytes = + encodedContentData.foldIndexed(ByteArray(encodedContentData.size)) { i, a, v -> + a.apply { + set( + i, + v.toByte() + ) + } } - } - val encodedContent = EncodedContent.parseFrom(encodedContentDataBytes) + val encodedContent = EncodedContent.parseFrom(encodedContentDataBytes) - conversation.send(encodedContent = encodedContent) + conversation.send(encodedContent = encodedContent) + } } AsyncFunction("listConversations") Coroutine { clientAddress: String -> - logV("listConversations") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val conversationList = client.conversations.list() - conversationList.map { conversation -> - conversations[conversation.cacheKey(clientAddress)] = conversation - if (conversation.keyMaterial == null) { - logV("Null key material before encode conversation") + withContext(Dispatchers.IO) { + logV("listConversations") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val conversationList = client.conversations.list() + conversationList.map { conversation -> + conversations[conversation.cacheKey(clientAddress)] = conversation + if (conversation.keyMaterial == null) { + logV("Null key material before encode conversation") + } + ConversationWrapper.encode(client, conversation) } - ConversationWrapper.encode(client, conversation) } } AsyncFunction("listGroups") Coroutine { 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) + withContext(Dispatchers.IO) { + 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("listAll") Coroutine { clientAddress: String -> - val client = clients[clientAddress] ?: throw XMTPException("No client") - val conversationContainerList = client.conversations.list(includeGroups = true) - conversationContainerList.map { conversation -> - conversations[conversation.cacheKey(clientAddress)] = conversation - ConversationContainerWrapper.encode(client, conversation) + withContext(Dispatchers.IO) { + val client = clients[clientAddress] ?: throw XMTPException("No client") + val conversationContainerList = client.conversations.list(includeGroups = true) + conversationContainerList.map { conversation -> + conversations[conversation.cacheKey(clientAddress)] = conversation + ConversationContainerWrapper.encode(client, conversation) + } } } AsyncFunction("loadMessages") Coroutine { clientAddress: String, topic: String, limit: Int?, before: Long?, after: Long?, direction: String? -> - logV("loadMessages") - val conversation = - findConversation( - clientAddress = clientAddress, - topic = topic, - ) ?: throw XMTPException("no conversation found for $topic") - val beforeDate = if (before != null) Date(before) else null - val afterDate = if (after != null) Date(after) else null - - conversation.decryptedMessages( - limit = limit, - before = beforeDate, - after = afterDate, - direction = MessageApiOuterClass.SortDirection.valueOf( - direction ?: "SORT_DIRECTION_DESCENDING" + withContext(Dispatchers.IO) { + logV("loadMessages") + val conversation = + findConversation( + clientAddress = clientAddress, + topic = topic, + ) ?: throw XMTPException("no conversation found for $topic") + val beforeDate = if (before != null) Date(before) else null + val afterDate = if (after != null) Date(after) else null + + conversation.decryptedMessages( + limit = limit, + before = beforeDate, + after = afterDate, + direction = MessageApiOuterClass.SortDirection.valueOf( + direction ?: "SORT_DIRECTION_DESCENDING" + ) ) - ) - .map { DecodedMessageWrapper.encode(it) } + .map { DecodedMessageWrapper.encode(it) } + } } AsyncFunction("groupMessages") Coroutine { clientAddress: String, id: String, limit: Int?, before: Long?, after: Long?, direction: String? -> - logV("groupMessages") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val beforeDate = if (before != null) Date(before) else null - val afterDate = if (after != null) Date(after) else null - val group = findGroup(clientAddress, id) - group?.decryptedMessages( - limit = limit, - before = beforeDate, - after = afterDate, - direction = MessageApiOuterClass.SortDirection.valueOf( - direction ?: "SORT_DIRECTION_DESCENDING" - ) - )?.map { DecodedMessageWrapper.encode(it) } + withContext(Dispatchers.IO) { + logV("groupMessages") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val beforeDate = if (before != null) Date(before) else null + val afterDate = if (after != null) Date(after) else null + val group = findGroup(clientAddress, id) + group?.decryptedMessages( + limit = limit, + before = beforeDate, + after = afterDate, + direction = MessageApiOuterClass.SortDirection.valueOf( + direction ?: "SORT_DIRECTION_DESCENDING" + ) + )?.map { DecodedMessageWrapper.encode(it) } + } } AsyncFunction("loadBatchMessages") Coroutine { clientAddress: String, topics: List -> - logV("loadBatchMessages") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val topicsList = mutableListOf>() - topics.forEach { - val jsonObj = JSONObject(it) - val topic = jsonObj.get("topic").toString() - var limit: Int? = null - var before: Long? = null - var after: Long? = null - var direction: MessageApiOuterClass.SortDirection = - MessageApiOuterClass.SortDirection.SORT_DIRECTION_DESCENDING + withContext(Dispatchers.IO) { + logV("loadBatchMessages") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val topicsList = mutableListOf>() + topics.forEach { + val jsonObj = JSONObject(it) + val topic = jsonObj.get("topic").toString() + var limit: Int? = null + var before: Long? = null + var after: Long? = null + var direction: MessageApiOuterClass.SortDirection = + MessageApiOuterClass.SortDirection.SORT_DIRECTION_DESCENDING + + try { + limit = jsonObj.get("limit").toString().toInt() + before = jsonObj.get("before").toString().toLong() + after = jsonObj.get("after").toString().toLong() + direction = MessageApiOuterClass.SortDirection.valueOf( + if (jsonObj.get("direction").toString().isNullOrBlank()) { + "SORT_DIRECTION_DESCENDING" + } else { + jsonObj.get("direction").toString() + } + ) + } catch (e: Exception) { + Log.e( + "XMTPModule", + "Pagination given incorrect information ${e.message}" + ) + } - try { - limit = jsonObj.get("limit").toString().toInt() - before = jsonObj.get("before").toString().toLong() - after = jsonObj.get("after").toString().toLong() - direction = MessageApiOuterClass.SortDirection.valueOf( - if (jsonObj.get("direction").toString().isNullOrBlank()) { - "SORT_DIRECTION_DESCENDING" - } else { - jsonObj.get("direction").toString() - } - ) - } catch (e: Exception) { - Log.e( - "XMTPModule", - "Pagination given incorrect information ${e.message}" + val page = Pagination( + limit = if (limit != null && limit > 0) limit else null, + before = if (before != null && before > 0) Date(before) else null, + after = if (after != null && after > 0) Date(after) else null, + direction = direction ) - } - val page = Pagination( - limit = if (limit != null && limit > 0) limit else null, - before = if (before != null && before > 0) Date(before) else null, - after = if (after != null && after > 0) Date(after) else null, - direction = direction - ) + topicsList.add(Pair(topic, page)) + } - topicsList.add(Pair(topic, page)) + client.conversations.listBatchDecryptedMessages(topicsList) + .map { DecodedMessageWrapper.encode(it) } } - - client.conversations.listBatchDecryptedMessages(topicsList) - .map { DecodedMessageWrapper.encode(it) } } AsyncFunction("sendMessage") Coroutine { clientAddress: String, conversationTopic: String, contentJson: String -> - logV("sendMessage") - val conversation = - findConversation( - clientAddress = clientAddress, - topic = conversationTopic + withContext(Dispatchers.IO) { + logV("sendMessage") + val conversation = + findConversation( + clientAddress = clientAddress, + topic = conversationTopic + ) + ?: throw XMTPException("no conversation found for $conversationTopic") + val sending = ContentJson.fromJson(contentJson) + conversation.send( + content = sending.content, + options = SendOptions(contentType = sending.type) ) - ?: throw XMTPException("no conversation found for $conversationTopic") - val sending = ContentJson.fromJson(contentJson) - conversation.send( - content = sending.content, - options = SendOptions(contentType = sending.type) - ) + } } AsyncFunction("sendMessageToGroup") Coroutine { clientAddress: String, id: String, contentJson: String -> - logV("sendMessageToGroup") - val group = - findGroup( - clientAddress = clientAddress, - id = id + withContext(Dispatchers.IO) { + logV("sendMessageToGroup") + val group = + findGroup( + clientAddress = clientAddress, + id = id + ) + ?: throw XMTPException("no group found for $id") + val sending = ContentJson.fromJson(contentJson) + group.send( + content = sending.content, + options = SendOptions(contentType = sending.type) ) - ?: throw XMTPException("no group found for $id") - val sending = ContentJson.fromJson(contentJson) - group.send( - content = sending.content, - options = SendOptions(contentType = sending.type) - ) + } } AsyncFunction("prepareMessage") Coroutine { clientAddress: String, conversationTopic: String, contentJson: String -> - logV("prepareMessage") - val conversation = - findConversation( - clientAddress = clientAddress, - topic = conversationTopic + withContext(Dispatchers.IO) { + logV("prepareMessage") + val conversation = + findConversation( + clientAddress = clientAddress, + topic = conversationTopic + ) + ?: throw XMTPException("no conversation found for $conversationTopic") + val sending = ContentJson.fromJson(contentJson) + val prepared = conversation.prepareMessage( + content = sending.content, + options = SendOptions(contentType = sending.type) ) - ?: throw XMTPException("no conversation found for $conversationTopic") - val sending = ContentJson.fromJson(contentJson) - val prepared = conversation.prepareMessage( - content = sending.content, - options = SendOptions(contentType = sending.type) - ) - val preparedAtMillis = prepared.envelopes[0].timestampNs / 1_000_000 - val preparedFile = File.createTempFile(prepared.messageId, null) - preparedFile.writeBytes(prepared.toSerializedData()) - PreparedLocalMessage( - messageId = prepared.messageId, - preparedFileUri = preparedFile.toURI().toString(), - preparedAt = preparedAtMillis, - ).toJson() + val preparedAtMillis = prepared.envelopes[0].timestampNs / 1_000_000 + val preparedFile = File.createTempFile(prepared.messageId, null) + preparedFile.writeBytes(prepared.toSerializedData()) + PreparedLocalMessage( + messageId = prepared.messageId, + preparedFileUri = preparedFile.toURI().toString(), + preparedAt = preparedAtMillis, + ).toJson() + } } AsyncFunction("prepareEncodedMessage") Coroutine { clientAddress: String, conversationTopic: String, encodedContentData: List -> - logV("prepareEncodedMessage") - val conversation = - findConversation( - clientAddress = clientAddress, - topic = conversationTopic - ) - ?: throw XMTPException("no conversation found for $conversationTopic") - - val encodedContentDataBytes = - encodedContentData.foldIndexed(ByteArray(encodedContentData.size)) { i, a, v -> - a.apply { - set( - i, - v.toByte() - ) + withContext(Dispatchers.IO) { + logV("prepareEncodedMessage") + val conversation = + findConversation( + clientAddress = clientAddress, + topic = conversationTopic + ) + ?: throw XMTPException("no conversation found for $conversationTopic") + + val encodedContentDataBytes = + encodedContentData.foldIndexed(ByteArray(encodedContentData.size)) { i, a, v -> + a.apply { + set( + i, + v.toByte() + ) + } } - } - val encodedContent = EncodedContent.parseFrom(encodedContentDataBytes) + val encodedContent = EncodedContent.parseFrom(encodedContentDataBytes) - val prepared = conversation.prepareMessage( - encodedContent = encodedContent, - ) - val preparedAtMillis = prepared.envelopes[0].timestampNs / 1_000_000 - val preparedFile = File.createTempFile(prepared.messageId, null) - preparedFile.writeBytes(prepared.toSerializedData()) - PreparedLocalMessage( - messageId = prepared.messageId, - preparedFileUri = preparedFile.toURI().toString(), - preparedAt = preparedAtMillis, - ).toJson() + val prepared = conversation.prepareMessage( + encodedContent = encodedContent, + ) + val preparedAtMillis = prepared.envelopes[0].timestampNs / 1_000_000 + val preparedFile = File.createTempFile(prepared.messageId, null) + preparedFile.writeBytes(prepared.toSerializedData()) + PreparedLocalMessage( + messageId = prepared.messageId, + preparedFileUri = preparedFile.toURI().toString(), + preparedAt = preparedAtMillis, + ).toJson() + } } AsyncFunction("sendPreparedMessage") Coroutine { clientAddress: String, preparedLocalMessageJson: String -> - logV("sendPreparedMessage") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val local = PreparedLocalMessage.fromJson(preparedLocalMessageJson) - val preparedFileUrl = Uri.parse(local.preparedFileUri) - val contentResolver = appContext.reactContext?.contentResolver!! - val preparedData = contentResolver.openInputStream(preparedFileUrl)!! - .use { it.buffered().readBytes() } - val prepared = PreparedMessage.fromSerializedData(preparedData) - client.publish(envelopes = prepared.envelopes) - try { - contentResolver.delete(preparedFileUrl, null, null) - } catch (ignore: Exception) { - /* ignore: the sending succeeds even if we fail to rm the tmp file afterward */ + withContext(Dispatchers.IO) { + logV("sendPreparedMessage") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val local = PreparedLocalMessage.fromJson(preparedLocalMessageJson) + val preparedFileUrl = Uri.parse(local.preparedFileUri) + val contentResolver = appContext.reactContext?.contentResolver!! + val preparedData = contentResolver.openInputStream(preparedFileUrl)!! + .use { it.buffered().readBytes() } + val prepared = PreparedMessage.fromSerializedData(preparedData) + client.publish(envelopes = prepared.envelopes) + try { + contentResolver.delete(preparedFileUrl, null, null) + } catch (ignore: Exception) { + /* ignore: the sending succeeds even if we fail to rm the tmp file afterward */ + } + prepared.messageId } - prepared.messageId } AsyncFunction("createConversation") Coroutine { clientAddress: String, peerAddress: String, contextJson: String -> - logV("createConversation: $contextJson") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val context = JsonParser.parseString(contextJson).asJsonObject - val conversation = client.conversations.newConversation( - peerAddress, - context = InvitationV1ContextBuilder.buildFromConversation( - conversationId = when { - context.has("conversationID") -> context.get("conversationID").asString - else -> "" - }, - metadata = when { - context.has("metadata") -> { - val metadata = context.get("metadata").asJsonObject - metadata.entrySet().associate { (key, value) -> key to value.asString } - } - - else -> mapOf() - }, + withContext(Dispatchers.IO) { + logV("createConversation: $contextJson") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val context = JsonParser.parseString(contextJson).asJsonObject + val conversation = client.conversations.newConversation( + peerAddress, + context = InvitationV1ContextBuilder.buildFromConversation( + conversationId = when { + context.has("conversationID") -> context.get("conversationID").asString + else -> "" + }, + metadata = when { + context.has("metadata") -> { + val metadata = context.get("metadata").asJsonObject + metadata.entrySet() + .associate { (key, value) -> key to value.asString } + } + + else -> mapOf() + }, + ) ) - ) - if (conversation.keyMaterial == null) { - logV("Null key material before encode conversation") + if (conversation.keyMaterial == null) { + logV("Null key material before encode conversation") + } + ConversationWrapper.encode(client, conversation) } - ConversationWrapper.encode(client, conversation) } - AsyncFunction("createGroup") Coroutine { clientAddress: String, peerAddresses: List, permission: String -> - logV("createGroup") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val permissionLevel = when (permission) { - "creator_admin" -> GroupPermissions.GROUP_CREATOR_IS_ADMIN - else -> GroupPermissions.EVERYONE_IS_ADMIN + withContext(Dispatchers.IO) { + logV("createGroup") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val permissionLevel = when (permission) { + "creator_admin" -> GroupPermissions.GROUP_CREATOR_IS_ADMIN + else -> GroupPermissions.EVERYONE_IS_ADMIN + } + val group = client.conversations.newGroup(peerAddresses, permissionLevel) + GroupWrapper.encode(client, group) } - val group = client.conversations.newGroup(peerAddresses, permissionLevel) - GroupWrapper.encode(client, group) } AsyncFunction("listMemberAddresses") Coroutine { clientAddress: String, groupId: String -> - logV("listMembers") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val group = findGroup(clientAddress, groupId) - group?.memberAddresses() + withContext(Dispatchers.IO) { + logV("listMembers") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val group = findGroup(clientAddress, groupId) + group?.memberAddresses() + } } - AsyncFunction("syncGroups") { clientAddress: String -> - logV("syncGroups") - val client = clients[clientAddress] ?: throw XMTPException("No client") - runBlocking { client.conversations.syncGroups() } + AsyncFunction("syncGroups") Coroutine { clientAddress: String -> + withContext(Dispatchers.IO) { + logV("syncGroups") + val client = clients[clientAddress] ?: throw XMTPException("No client") + client.conversations.syncGroups() + } } AsyncFunction("syncGroup") Coroutine { clientAddress: String, id: String -> - logV("syncGroup") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val group = findGroup(clientAddress, id) - runBlocking { group?.sync() } + withContext(Dispatchers.IO) { + logV("syncGroup") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val group = findGroup(clientAddress, id) + group?.sync() + } } AsyncFunction("addGroupMembers") Coroutine { clientAddress: String, id: String, peerAddresses: List -> - logV("addGroupMembers") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val group = findGroup(clientAddress, id) + withContext(Dispatchers.IO) { + logV("addGroupMembers") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val group = findGroup(clientAddress, id) - runBlocking { group?.addMembers(peerAddresses) } + group?.addMembers(peerAddresses) + } } AsyncFunction("removeGroupMembers") Coroutine { clientAddress: String, id: String, peerAddresses: List -> - logV("removeGroupMembers") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val group = findGroup(clientAddress, id) + withContext(Dispatchers.IO) { + logV("removeGroupMembers") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val group = findGroup(clientAddress, id) - runBlocking { group?.removeMembers(peerAddresses) } + group?.removeMembers(peerAddresses) + } } AsyncFunction("isGroupActive") Coroutine { clientAddress: String, id: String -> - logV("isGroupActive") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val group = findGroup(clientAddress, id) + withContext(Dispatchers.IO) { + logV("isGroupActive") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val group = findGroup(clientAddress, id) - group?.isActive() + group?.isActive() + } } AsyncFunction("isGroupAdmin") Coroutine { clientAddress: String, id: String -> - logV("isGroupAdmin") - val client = clients[clientAddress] ?: throw XMTPException("No client") - val group = findGroup(clientAddress, id) + withContext(Dispatchers.IO) { + logV("isGroupAdmin") + val client = clients[clientAddress] ?: throw XMTPException("No client") + val group = findGroup(clientAddress, id) - group?.isAdmin() + group?.isAdmin() + } } Function("subscribeToConversations") { clientAddress: String -> @@ -794,19 +838,23 @@ class XMTPModule : Module() { } AsyncFunction("subscribeToMessages") Coroutine { clientAddress: String, topic: String -> - logV("subscribeToMessages") - subscribeToMessages( - clientAddress = clientAddress, - topic = topic - ) + withContext(Dispatchers.IO) { + logV("subscribeToMessages") + subscribeToMessages( + clientAddress = clientAddress, + topic = topic + ) + } } AsyncFunction("subscribeToGroupMessages") Coroutine { clientAddress: String, id: String -> - logV("subscribeToGroupMessages") - subscribeToGroupMessages( - clientAddress = clientAddress, - id = id - ) + withContext(Dispatchers.IO) { + logV("subscribeToGroupMessages") + subscribeToGroupMessages( + clientAddress = clientAddress, + id = id + ) + } } Function("unsubscribeFromConversations") { clientAddress: String -> @@ -830,19 +878,23 @@ class XMTPModule : Module() { } AsyncFunction("unsubscribeFromMessages") Coroutine { clientAddress: String, topic: String -> - logV("unsubscribeFromMessages") - unsubscribeFromMessages( - clientAddress = clientAddress, - topic = topic - ) + withContext(Dispatchers.IO) { + logV("unsubscribeFromMessages") + unsubscribeFromMessages( + clientAddress = clientAddress, + topic = topic + ) + } } AsyncFunction("unsubscribeFromGroupMessages") Coroutine { clientAddress: String, id: String -> - logV("unsubscribeFromGroupMessages") - unsubscribeFromGroupMessages( - clientAddress = clientAddress, - id = id - ) + withContext(Dispatchers.IO) { + logV("unsubscribeFromGroupMessages") + unsubscribeFromGroupMessages( + clientAddress = clientAddress, + id = id + ) + } } Function("registerPushToken") { pushServer: String, token: String -> @@ -862,17 +914,19 @@ class XMTPModule : Module() { } AsyncFunction("decodeMessage") Coroutine { clientAddress: String, topic: String, encryptedMessage: String -> - logV("decodeMessage") - val encryptedMessageData = Base64.decode(encryptedMessage, NO_WRAP) - val envelope = EnvelopeBuilder.buildFromString(topic, Date(), encryptedMessageData) - val conversation = - findConversation( - clientAddress = clientAddress, - topic = topic - ) - ?: throw XMTPException("no conversation found for $topic") - val decodedMessage = conversation.decrypt(envelope) - DecodedMessageWrapper.encode(decodedMessage) + withContext(Dispatchers.IO) { + logV("decodeMessage") + val encryptedMessageData = Base64.decode(encryptedMessage, NO_WRAP) + val envelope = EnvelopeBuilder.buildFromString(topic, Date(), encryptedMessageData) + val conversation = + findConversation( + clientAddress = clientAddress, + topic = topic + ) + ?: throw XMTPException("no conversation found for $topic") + val decodedMessage = conversation.decrypt(envelope) + DecodedMessageWrapper.encode(decodedMessage) + } } AsyncFunction("isAllowed") { clientAddress: String, address: String -> @@ -888,14 +942,18 @@ class XMTPModule : Module() { } AsyncFunction("denyContacts") Coroutine { clientAddress: String, addresses: List -> - logV("denyContacts") - val client = clients[clientAddress] ?: throw XMTPException("No client") - client.contacts.deny(addresses) + withContext(Dispatchers.IO) { + logV("denyContacts") + val client = clients[clientAddress] ?: throw XMTPException("No client") + client.contacts.deny(addresses) + } } AsyncFunction("allowContacts") Coroutine { clientAddress: String, addresses: List -> - val client = clients[clientAddress] ?: throw XMTPException("No client") - client.contacts.allow(addresses) + withContext(Dispatchers.IO) { + val client = clients[clientAddress] ?: throw XMTPException("No client") + client.contacts.allow(addresses) + } } AsyncFunction("refreshConsentList") { clientAddress: String -> @@ -905,9 +963,11 @@ class XMTPModule : Module() { } AsyncFunction("conversationConsentState") Coroutine { clientAddress: String, conversationTopic: String -> - val conversation = findConversation(clientAddress, conversationTopic) - ?: throw XMTPException("no conversation found for $conversationTopic") - consentStateToString(conversation.consentState()) + withContext(Dispatchers.IO) { + val conversation = findConversation(clientAddress, conversationTopic) + ?: throw XMTPException("no conversation found for $conversationTopic") + consentStateToString(conversation.consentState()) + } } AsyncFunction("consentList") { clientAddress: String -> diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt index 775ef66a5..6b87624f7 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/wrappers/DecodedMessageWrapper.kt @@ -13,14 +13,19 @@ class DecodedMessageWrapper { return gson.toJson(message) } - fun encodeMap(model: DecryptedMessage): Map = mapOf( - "id" to model.id, - "topic" to model.topic, - "contentTypeId" to model.encodedContent.type.description, - "content" to ContentJson(model.encodedContent).toJsonMap(), - "senderAddress" to model.senderAddress, - "sent" to model.sentAt.time, - "fallback" to model.encodedContent.fallback - ) + fun encodeMap(model: DecryptedMessage): Map { + // Kotlin/Java Protos don't support null values and will always put the default "" + // Check if there is a fallback, if there is then make it the set fallback, if not null + val fallback = if (model.encodedContent.hasFallback()) model.encodedContent.fallback else null + return mapOf( + "id" to model.id, + "topic" to model.topic, + "contentTypeId" to model.encodedContent.type.description, + "content" to ContentJson(model.encodedContent).toJsonMap(), + "senderAddress" to model.senderAddress, + "sent" to model.sentAt.time, + "fallback" to fallback + ) + } } } diff --git a/example/src/tests/tests.ts b/example/src/tests/tests.ts index 90511e3e3..c996de5ad 100644 --- a/example/src/tests/tests.ts +++ b/example/src/tests/tests.ts @@ -32,6 +32,20 @@ const ContentTypeNumber: ContentTypeId = { versionMinor: 0, } +const ContentTypeNumberWithUndefinedFallback: ContentTypeId = { + authorityId: 'org', + typeId: 'number_undefined_fallback', + versionMajor: 1, + versionMinor: 0, +} + +const ContentTypeNumberWithEmptyFallback: ContentTypeId = { + authorityId: 'org', + typeId: 'number_empty_fallback', + versionMajor: 1, + versionMinor: 0, +} + export type NumberRef = { topNumber: { bottomNumber: number @@ -67,6 +81,20 @@ class NumberCodec implements JSContentCodec { } } +class NumberCodecUndefinedFallback extends NumberCodec { + contentType = ContentTypeNumberWithUndefinedFallback + fallback(content: NumberRef): string | undefined { + return undefined + } +} + +class NumberCodecEmptyFallback extends NumberCodec { + contentType = ContentTypeNumberWithEmptyFallback + fallback(content: NumberRef): string | undefined { + return '' + } +} + const LONG_STREAM_DELAY = 20000 export const tests: Test[] = [] @@ -216,8 +244,12 @@ test('can load 1995 conversations from dev network "2k lens convos" account', as xmtpClient.address === '0x209fAEc92D9B072f3E03d6115002d6652ef563cd', 'Address: ' + xmtpClient.address ) - + let start = Date.now() const conversations = await xmtpClient.conversations.list() + let end = Date.now() + console.log( + `Loaded ${conversations.length} conversations in ${end - start}ms` + ) assert( conversations.length === 1995, 'Conversations: ' + conversations.length @@ -1194,6 +1226,62 @@ test('correctly handles lowercase addresses', async () => { return true }) +test('handle fallback types appropriately', async () => { + const bob = await Client.createRandom({ + env: 'local', + codecs: [ + new NumberCodecEmptyFallback(), + new NumberCodecUndefinedFallback(), + ], + }) + const alice = await Client.createRandom({ + env: 'local', + }) + bob.register(new NumberCodecEmptyFallback()) + bob.register(new NumberCodecUndefinedFallback()) + const bobConvo = await bob.conversations.newConversation(alice.address) + const aliceConvo = await alice.conversations.newConversation(bob.address) + + await bobConvo.send(12, { contentType: ContentTypeNumberWithEmptyFallback }) + + await bobConvo.send(12, { + contentType: ContentTypeNumberWithUndefinedFallback, + }) + + const messages = await aliceConvo.messages() + assert(messages.length === 2, 'did not get messages') + + const messageUndefinedFallback = messages[0] + const messageWithDefinedFallback = messages[1] + + let message1Content = undefined + try { + message1Content = messageUndefinedFallback.content() + } catch { + message1Content = messageUndefinedFallback.fallback + } + + assert( + message1Content === undefined, + 'did not get content properly when empty fallback: ' + + JSON.stringify(message1Content) + ) + + let message2Content = undefined + try { + message2Content = messageWithDefinedFallback.content() + } catch { + message2Content = messageWithDefinedFallback.fallback + } + + assert( + message2Content === '', + 'did not get content properly: ' + JSON.stringify(message2Content) + ) + + return true +}) + test('instantiate frames client correctly', async () => { const frameUrl = 'https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8' diff --git a/ios/Wrappers/DecodedMessageWrapper.swift b/ios/Wrappers/DecodedMessageWrapper.swift index e9b5ed39f..b9780396d 100644 --- a/ios/Wrappers/DecodedMessageWrapper.swift +++ b/ios/Wrappers/DecodedMessageWrapper.swift @@ -5,6 +5,9 @@ import XMTP // into react native. struct DecodedMessageWrapper { static func encodeToObj(_ model: XMTP.DecryptedMessage, client: Client) throws -> [String: Any] { + // Swift Protos don't support null values and will always put the default "" + // Check if there is a fallback, if there is then make it the set fallback, if not null + let fallback = model.encodedContent.hasFallback ? model.encodedContent.fallback : nil return [ "id": model.id, "topic": model.topic, @@ -12,7 +15,7 @@ struct DecodedMessageWrapper { "content": try ContentJson.fromEncoded(model.encodedContent, client: client).toJsonMap() as Any, "senderAddress": model.senderAddress, "sent": UInt64(model.sentAt.timeIntervalSince1970 * 1000), - "fallback": model.encodedContent.fallback, + "fallback": fallback, ] } diff --git a/src/lib/DecodedMessage.ts b/src/lib/DecodedMessage.ts index 24321a412..244dcfb49 100644 --- a/src/lib/DecodedMessage.ts +++ b/src/lib/DecodedMessage.ts @@ -85,7 +85,8 @@ export class DecodedMessage< this.senderAddress = senderAddress this.sent = sent this.nativeContent = content - this.fallback = fallback + // undefined comes back as null when bridged, ensure undefined so integrators don't have to add a new check for null as well + this.fallback = fallback ?? undefined } content(): ExtractDecodedType<[...ContentTypes, TextCodec][number] | string> {