From 8e971cb635d3779cede48d751865b2782d86dbe3 Mon Sep 17 00:00:00 2001 From: cameronvoell Date: Thu, 5 Sep 2024 09:57:39 -0700 Subject: [PATCH] functions for mls dms --- .../org/xmtp/android/library/Conversation.kt | 29 +- .../org/xmtp/android/library/Conversations.kt | 24 +- .../main/java/org/xmtp/android/library/Dm.kt | 420 ++++++++++++++++++ 3 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 library/src/main/java/org/xmtp/android/library/Dm.kt diff --git a/library/src/main/java/org/xmtp/android/library/Conversation.kt b/library/src/main/java/org/xmtp/android/library/Conversation.kt index b8db34d09..01dc43f27 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversation.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversation.kt @@ -28,8 +28,9 @@ sealed class Conversation { data class V2(val conversationV2: ConversationV2) : Conversation() data class Group(val group: org.xmtp.android.library.Group) : Conversation() + data class Dm(val dm: org.xmtp.android.library.Dm) : Conversation() - enum class Version { V1, V2, GROUP } + enum class Version { V1, V2, GROUP, DM } // This indicates whether this a v1 or v2 conversation. val version: Version @@ -38,6 +39,7 @@ sealed class Conversation { is V1 -> Version.V1 is V2 -> Version.V2 is Group -> Version.GROUP + is Dm -> Version.DM } } @@ -48,6 +50,7 @@ sealed class Conversation { is V1 -> conversationV1.sentAt is V2 -> conversationV2.createdAt is Group -> group.createdAt + is Dm -> dm.createdAt } } @@ -58,6 +61,7 @@ sealed class Conversation { is V1 -> conversationV1.peerAddress is V2 -> conversationV2.peerAddress is Group -> runBlocking { group.peerInboxIds().joinToString(",") } + is Dm -> runBlocking { dm.peerInboxIds().joinToString(",") } } } @@ -67,6 +71,7 @@ sealed class Conversation { is V1 -> listOf(conversationV1.peerAddress) is V2 -> listOf(conversationV2.peerAddress) is Group -> runBlocking { group.peerInboxIds() } + is Dm -> runBlocking { dm.peerInboxIds() } } } @@ -78,6 +83,7 @@ sealed class Conversation { is V1 -> null is V2 -> conversationV2.context.conversationId is Group -> null + is Dm -> null } } @@ -87,6 +93,7 @@ sealed class Conversation { is V1 -> null is V2 -> conversationV2.keyMaterial is Group -> null + is Dm -> null } } @@ -95,6 +102,7 @@ sealed class Conversation { is V1 -> conversationV1.client.contacts.consentList.state(address = peerAddress) is V2 -> conversationV2.client.contacts.consentList.state(address = peerAddress) is Group -> group.consentState() + is Dm -> dm.client.contacts.consentList.groupState(groupId = dm.id) } } @@ -120,6 +128,7 @@ sealed class Conversation { ).build() is Group -> throw XMTPException("Groups do not support topics") + is Dm -> throw XMTPException("DMs do not support topics") } } @@ -128,6 +137,7 @@ sealed class Conversation { is V1 -> conversationV1.decode(envelope) is V2 -> conversationV2.decodeEnvelope(envelope) is Group -> message?.decode() ?: throw XMTPException("Groups require message be passed") + is Dm -> throw XMTPException("DMs require message be passed") } } @@ -151,6 +161,7 @@ sealed class Conversation { } is Group -> throw XMTPException("Groups do not support prepared messages") // We return a encoded content not a preparedmessage which requires a envelope + is Dm -> throw XMTPException("DMs do not support prepared messages") } } @@ -168,6 +179,7 @@ sealed class Conversation { } is Group -> throw XMTPException("Groups do not support prepared messages") // We return a encoded content not a preparedmessage which requires a envelope + is Dm -> throw XMTPException("DMs do not support prepared messages") } } @@ -176,6 +188,7 @@ sealed class Conversation { is V1 -> conversationV1.send(prepared = prepared) is V2 -> conversationV2.send(prepared = prepared) is Group -> throw XMTPException("Groups do not support prepared messages") // We return a encoded content not a prepared Message which requires a envelope + is Dm -> throw XMTPException("DMs do not support prepared messages") } } @@ -184,6 +197,7 @@ sealed class Conversation { is V1 -> conversationV1.send(content = content, options = options) is V2 -> conversationV2.send(content = content, options = options) is Group -> group.send(content = content, options = options) + is Dm -> dm.send(content = content, options = options) } } @@ -192,6 +206,7 @@ sealed class Conversation { is V1 -> conversationV1.send(text = text, sendOptions, sentAt) is V2 -> conversationV2.send(text = text, sendOptions, sentAt) is Group -> group.send(text) + is Dm -> dm.send(text) } } @@ -200,6 +215,7 @@ sealed class Conversation { is V1 -> conversationV1.send(encodedContent = encodedContent, options = options) is V2 -> conversationV2.send(encodedContent = encodedContent, options = options) is Group -> group.send(encodedContent = encodedContent) + is Dm -> dm.send(encodedContent = encodedContent) } } @@ -215,6 +231,7 @@ sealed class Conversation { is V1 -> conversationV1.topic.description is V2 -> conversationV2.topic is Group -> group.topic + is Dm -> dm.topic } } @@ -261,6 +278,7 @@ sealed class Conversation { direction = direction, ) } + is Dm -> dm.messages(limit, before, after, direction) } } @@ -274,6 +292,7 @@ sealed class Conversation { is V1 -> conversationV1.decryptedMessages(limit, before, after, direction) is V2 -> conversationV2.decryptedMessages(limit, before, after, direction) is Group -> group.decryptedMessages(limit, before, after, direction) + is Dm -> dm.decryptedMessages(limit, before, after, direction) } } @@ -287,6 +306,9 @@ sealed class Conversation { is Group -> { message?.decrypt() ?: throw XMTPException("Groups require message be passed") } + is Dm -> { + message?.decrypt() ?: throw XMTPException("DMs require message be passed") + } } } @@ -296,6 +318,7 @@ sealed class Conversation { is V1 -> return null is V2 -> conversationV2.consentProof is Group -> return null + is Dm -> return null } } @@ -306,6 +329,7 @@ sealed class Conversation { is V1 -> conversationV1.client is V2 -> conversationV2.client is Group -> group.client + is Dm -> dm.client } } @@ -318,6 +342,7 @@ sealed class Conversation { is V1 -> conversationV1.streamMessages() is V2 -> conversationV2.streamMessages() is Group -> group.streamMessages() + is Dm -> dm.streamMessages() } } @@ -326,6 +351,7 @@ sealed class Conversation { is V1 -> conversationV1.streamDecryptedMessages() is V2 -> conversationV2.streamDecryptedMessages() is Group -> group.streamDecryptedMessages() + is Dm -> dm.streamDecryptedMessages() } } @@ -334,6 +360,7 @@ sealed class Conversation { is V1 -> return conversationV1.streamEphemeral() is V2 -> return conversationV2.streamEphemeral() is Group -> throw XMTPException("Groups do not support ephemeral messages") + is Dm -> throw XMTPException("DMs do not support ephemeral messages") } } } diff --git a/library/src/main/java/org/xmtp/android/library/Conversations.kt b/library/src/main/java/org/xmtp/android/library/Conversations.kt index d2d807106..57dc1394a 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversations.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversations.kt @@ -129,6 +129,11 @@ data class Conversations( ) } + // mark this as private until we enable we sunset V2 + private suspend fun newDm(accountAddress: String): Dm { + return newDmInternal(accountAddress) + } + suspend fun newGroupCustomPermissions( accountAddresses: List, permissionPolicySet: PermissionPolicySet, @@ -186,6 +191,21 @@ data class Conversations( return Group(client, group) } + private suspend fun newDmInternal( + accountAddress: String, + ): Dm { + if (accountAddress.lowercase() == client.address.lowercase()) { + throw XMTPException("Recipient is sender") + } + + val inboxId = client.inboxIdFromAddress(accountAddress) ?: throw XMTPException("Error getting inbox id, ${accountAddress} not on network") + val dm = + libXMTPConversations?.createDm(inboxId)?: throw XMTPException("Client does not support V3 Dms") + client.contacts.allowGroups(groupIds = listOf(dm.id().toHex())) + + return Dm(client, dm) + } + // Sync from the network the latest list of groups suspend fun syncGroups() { libXMTPConversations?.sync() @@ -245,9 +265,9 @@ data class Conversations( throw XMTPException("Recipient is sender") } if (client.v3Client != null) { - val conversationV3 = libXMTPConversations?.createDm(peerAddress) + val conversationV3 = libXMTPConversations?.createDm(peerAddress) ?: throw XMTPException("Client does not support V3 Dms") if (!client.hasV2Client) { - val conversation = Conversation.V3(conversationV3) + val conversation = Conversation.Dm(Dm(client, conversationV3)) conversationsByTopic[conversation.topic] = conversation return conversation } diff --git a/library/src/main/java/org/xmtp/android/library/Dm.kt b/library/src/main/java/org/xmtp/android/library/Dm.kt new file mode 100644 index 000000000..0b0952d91 --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/Dm.kt @@ -0,0 +1,420 @@ +package org.xmtp.android.library + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.xmtp.android.library.Client +import org.xmtp.android.library.ConsentState +import org.xmtp.android.library.DecodedMessage +import org.xmtp.android.library.SendOptions +import org.xmtp.android.library.XMTPException +import org.xmtp.android.library.codecs.ContentCodec +import org.xmtp.android.library.codecs.EncodedContent +import org.xmtp.android.library.codecs.compress +import org.xmtp.android.library.libxmtp.Member +import org.xmtp.android.library.libxmtp.MessageV3 +import org.xmtp.android.library.messages.DecryptedMessage +import org.xmtp.android.library.messages.MessageDeliveryStatus +import org.xmtp.android.library.messages.PagingInfoSortDirection +import org.xmtp.android.library.messages.Topic +import org.xmtp.android.library.toHex +import org.xmtp.proto.message.api.v1.MessageApiOuterClass +import uniffi.xmtpv3.FfiDeliveryStatus +import uniffi.xmtpv3.FfiGroup +import uniffi.xmtpv3.FfiGroupMetadata +import uniffi.xmtpv3.FfiGroupPermissions +import uniffi.xmtpv3.FfiListMessagesOptions +import uniffi.xmtpv3.FfiMessage +import uniffi.xmtpv3.FfiMessageCallback +import uniffi.xmtpv3.FfiMetadataField +import uniffi.xmtpv3.FfiPermissionUpdateType +import uniffi.xmtpv3.org.xmtp.android.library.libxmtp.PermissionOption +import uniffi.xmtpv3.org.xmtp.android.library.libxmtp.PermissionPolicySet +import java.util.Date +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.DurationUnit + +class Dm(val client: Client, private val libXMTPGroup: FfiGroup) { + val id: String + get() = libXMTPGroup.id().toHex() + + val topic: String + get() = Topic.groupMessage(id).description + + val createdAt: Date + get() = Date(libXMTPGroup.createdAtNs() / 1_000_000) + + private val metadata: FfiGroupMetadata + get() = libXMTPGroup.groupMetadata() + + private val permissions: FfiGroupPermissions + get() = libXMTPGroup.groupPermissions() + + val name: String + get() = libXMTPGroup.groupName() + + val imageUrlSquare: String + get() = libXMTPGroup.groupImageUrlSquare() + + val description: String + get() = libXMTPGroup.groupDescription() + + val pinnedFrameUrl: String + get() = libXMTPGroup.groupPinnedFrameUrl() + + suspend fun send(text: String): String { + return send(encodeContent(content = text, options = null)) + } + + suspend fun send(content: T, options: SendOptions? = null): String { + val preparedMessage = encodeContent(content = content, options = options) + return send(preparedMessage) + } + + suspend fun send(encodedContent: EncodedContent): String { + if (client.contacts.consentList.groupState(groupId = id) == ConsentState.UNKNOWN) { + client.contacts.allowGroups(groupIds = listOf(id)) + } + val messageId = libXMTPGroup.send(contentBytes = encodedContent.toByteArray()) + return messageId.toHex() + } + + fun encodeContent(content: T, options: SendOptions?): EncodedContent { + val codec = Client.codecRegistry.find(options?.contentType) + + fun > encode(codec: Codec, content: Any?): EncodedContent { + val contentType = content as? T + if (contentType != null) { + return codec.encode(contentType) + } else { + throw XMTPException("Codec type is not registered") + } + } + + var encoded = encode(codec = codec as ContentCodec, content = content) + val fallback = codec.fallback(content) + if (!fallback.isNullOrBlank()) { + encoded = encoded.toBuilder().also { + it.fallback = fallback + }.build() + } + val compression = options?.compression + if (compression != null) { + encoded = encoded.compress(compression) + } + return encoded + } + + suspend fun prepareMessage(content: T, options: SendOptions? = null): String { + if (client.contacts.consentList.groupState(groupId = id) == ConsentState.UNKNOWN) { + client.contacts.allowGroups(groupIds = listOf(id)) + } + val encodeContent = encodeContent(content = content, options = options) + return libXMTPGroup.sendOptimistic(encodeContent.toByteArray()).toHex() + } + + suspend fun publishMessages() { + libXMTPGroup.publishMessages() + } + + suspend fun sync() { + libXMTPGroup.sync() + } + + fun messages( + limit: Int? = null, + before: Date? = null, + after: Date? = null, + direction: PagingInfoSortDirection = MessageApiOuterClass.SortDirection.SORT_DIRECTION_DESCENDING, + deliveryStatus: MessageDeliveryStatus = MessageDeliveryStatus.ALL, + ): List { + val messages = libXMTPGroup.findMessages( + opts = FfiListMessagesOptions( + sentBeforeNs = before?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + sentAfterNs = after?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + limit = limit?.toLong(), + deliveryStatus = when (deliveryStatus) { + MessageDeliveryStatus.PUBLISHED -> FfiDeliveryStatus.PUBLISHED + MessageDeliveryStatus.UNPUBLISHED -> FfiDeliveryStatus.UNPUBLISHED + MessageDeliveryStatus.FAILED -> FfiDeliveryStatus.FAILED + else -> null + } + ) + ).mapNotNull { + MessageV3(client, it).decodeOrNull() + } + + return when (direction) { + MessageApiOuterClass.SortDirection.SORT_DIRECTION_ASCENDING -> messages + else -> messages.reversed() + } + } + + fun decryptedMessages( + limit: Int? = null, + before: Date? = null, + after: Date? = null, + direction: PagingInfoSortDirection = MessageApiOuterClass.SortDirection.SORT_DIRECTION_DESCENDING, + deliveryStatus: MessageDeliveryStatus = MessageDeliveryStatus.ALL, + ): List { + val messages = libXMTPGroup.findMessages( + opts = FfiListMessagesOptions( + sentBeforeNs = before?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + sentAfterNs = after?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + limit = limit?.toLong(), + deliveryStatus = when (deliveryStatus) { + MessageDeliveryStatus.PUBLISHED -> FfiDeliveryStatus.PUBLISHED + MessageDeliveryStatus.UNPUBLISHED -> FfiDeliveryStatus.UNPUBLISHED + MessageDeliveryStatus.FAILED -> FfiDeliveryStatus.FAILED + else -> null + } + ) + ).mapNotNull { + MessageV3(client, it).decryptOrNull() + } + + return when (direction) { + MessageApiOuterClass.SortDirection.SORT_DIRECTION_ASCENDING -> messages + else -> messages.reversed() + } + } + + suspend fun processMessage(envelopeBytes: ByteArray): MessageV3 { + val message = libXMTPGroup.processStreamedGroupMessage(envelopeBytes) + return MessageV3(client, message) + } + + fun isActive(): Boolean { + return libXMTPGroup.isActive() + } + + fun addedByInboxId(): String { + return libXMTPGroup.addedByInboxId() + } + + fun permissionPolicySet(): PermissionPolicySet { + return PermissionPolicySet.fromFfiPermissionPolicySet(permissions.policySet()) + } + + fun creatorInboxId(): String { + return metadata.creatorInboxId() + } + + fun isCreator(): Boolean { + return metadata.creatorInboxId() == client.inboxId + } + + suspend fun addMembers(addresses: List) { + try { + libXMTPGroup.addMembers(addresses) + } catch (e: Exception) { + throw XMTPException("Unable to add member", e) + } + } + + suspend fun removeMembers(addresses: List) { + try { + libXMTPGroup.removeMembers(addresses) + } catch (e: Exception) { + throw XMTPException("Unable to remove member", e) + } + } + + suspend fun addMembersByInboxId(inboxIds: List) { + try { + libXMTPGroup.addMembersByInboxId(inboxIds) + } catch (e: Exception) { + throw XMTPException("Unable to add member", e) + } + } + + suspend fun removeMembersByInboxId(inboxIds: List) { + try { + libXMTPGroup.removeMembersByInboxId(inboxIds) + } catch (e: Exception) { + throw XMTPException("Unable to remove member", e) + } + } + + suspend fun members(): List { + return libXMTPGroup.listMembers().map { Member(it) } + } + + suspend fun peerInboxIds(): List { + val ids = members().map { it.inboxId }.toMutableList() + ids.remove(client.inboxId) + return ids + } + + suspend fun updateGroupName(name: String) { + try { + return libXMTPGroup.updateGroupName(name) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to update group name", e) + } + } + + suspend fun updateGroupImageUrlSquare(imageUrl: String) { + try { + return libXMTPGroup.updateGroupImageUrlSquare(imageUrl) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to update image url", e) + } + } + + suspend fun updateGroupDescription(description: String) { + try { + return libXMTPGroup.updateGroupDescription(description) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to update group description", e) + } + } + + suspend fun updateGroupPinnedFrameUrl(pinnedFrameUrl: String) { + try { + return libXMTPGroup.updateGroupPinnedFrameUrl(pinnedFrameUrl) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to update pinned frame", e) + } + } + + suspend fun updateAddMemberPermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.ADD_MEMBER, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + null + ) + } + + suspend fun updateRemoveMemberPermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.REMOVE_MEMBER, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + null + ) + } + + suspend fun updateAddAdminPermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.ADD_ADMIN, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + null + ) + } + + suspend fun updateRemoveAdminPermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.REMOVE_ADMIN, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + null + ) + } + + suspend fun updateGroupNamePermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.UPDATE_METADATA, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + FfiMetadataField.GROUP_NAME + ) + } + + suspend fun updateGroupDescriptionPermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.UPDATE_METADATA, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + FfiMetadataField.DESCRIPTION + ) + } + + suspend fun updateGroupImageUrlSquarePermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.UPDATE_METADATA, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + FfiMetadataField.IMAGE_URL_SQUARE + ) + } + + suspend fun updateGroupPinnedFrameUrlPermission(newPermissionOption: PermissionOption) { + return libXMTPGroup.updatePermissionPolicy( + FfiPermissionUpdateType.UPDATE_METADATA, + PermissionOption.toFfiPermissionPolicy(newPermissionOption), + FfiMetadataField.PINNED_FRAME_URL + ) + } + + fun isAdmin(inboxId: String): Boolean { + return libXMTPGroup.isAdmin(inboxId) + } + + fun isSuperAdmin(inboxId: String): Boolean { + return libXMTPGroup.isSuperAdmin(inboxId) + } + + suspend fun addAdmin(inboxId: String) { + try { + libXMTPGroup.addAdmin(inboxId) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to add admin", e) + } + } + + suspend fun removeAdmin(inboxId: String) { + try { + libXMTPGroup.removeAdmin(inboxId) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to remove admin", e) + } + } + + suspend fun addSuperAdmin(inboxId: String) { + try { + libXMTPGroup.addSuperAdmin(inboxId) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to add super admin", e) + } + } + + suspend fun removeSuperAdmin(inboxId: String) { + try { + libXMTPGroup.removeSuperAdmin(inboxId) + } catch (e: Exception) { + throw XMTPException("Permission denied: Unable to remove super admin", e) + } + } + + suspend fun listAdmins(): List { + return libXMTPGroup.adminList() + } + + suspend fun listSuperAdmins(): List { + return libXMTPGroup.superAdminList() + } + + fun streamMessages(): Flow = callbackFlow { + val messageCallback = object : FfiMessageCallback { + override fun onMessage(message: FfiMessage) { + val decodedMessage = MessageV3(client, message).decodeOrNull() + decodedMessage?.let { + trySend(it) + } + } + } + + val stream = libXMTPGroup.stream(messageCallback) + awaitClose { stream.end() } + } + + fun streamDecryptedMessages(): Flow = callbackFlow { + val messageCallback = object : FfiMessageCallback { + override fun onMessage(message: FfiMessage) { + val decryptedMessage = MessageV3(client, message).decryptOrNull() + decryptedMessage?.let { + trySend(it) + } + } + } + + val stream = libXMTPGroup.stream(messageCallback) + awaitClose { stream.end() } + } +}