From e1eec1d1956f275a89487e590585617d32928391 Mon Sep 17 00:00:00 2001 From: tuddman Date: Wed, 21 Feb 2024 17:08:39 +0100 Subject: [PATCH] group preference actions (#180) --- library/build.gradle | 2 +- .../org/xmtp/android/library/ContactsTest.kt | 6 +- .../org/xmtp/android/library/GroupTest.kt | 74 +++++-- .../java/org/xmtp/android/library/Contacts.kt | 189 +++++++++++++----- .../org/xmtp/android/library/Conversation.kt | 4 +- .../org/xmtp/android/library/Conversations.kt | 2 + .../java/org/xmtp/android/library/Group.kt | 3 + .../org/xmtp/android/library/TestHelpers.kt | 10 +- 8 files changed, 211 insertions(+), 79 deletions(-) diff --git a/library/build.gradle b/library/build.gradle index aee6d99ff..126525620 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -86,7 +86,7 @@ dependencies { implementation 'org.web3j:crypto:5.0.0' implementation "net.java.dev.jna:jna:5.13.0@aar" api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3' - api 'org.xmtp:proto-kotlin:3.40.1' + api 'org.xmtp:proto-kotlin:3.43.2' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'app.cash.turbine:turbine:0.12.1' diff --git a/library/src/androidTest/java/org/xmtp/android/library/ContactsTest.kt b/library/src/androidTest/java/org/xmtp/android/library/ContactsTest.kt index 7cb2f6104..6cccc9b99 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/ContactsTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/ContactsTest.kt @@ -12,8 +12,8 @@ class ContactsTest { fun testNormalizesAddresses() { val fixtures = fixtures() fixtures.bobClient.ensureUserContactPublished() - val bobAddressLowercased = fixtures.bobClient.address?.lowercase() - val bobContact = fixtures.aliceClient.getUserContact(peerAddress = bobAddressLowercased!!) + val bobAddressLowerCased = fixtures.bobClient.address.lowercase() + val bobContact = fixtures.aliceClient.getUserContact(peerAddress = bobAddressLowerCased) assert(bobContact != null) } @@ -54,7 +54,7 @@ class ContactsTest { } @Test - fun testBlockAddress() { + fun testDenyAddress() { val fixtures = fixtures() val contacts = fixtures.bobClient.contacts diff --git a/library/src/androidTest/java/org/xmtp/android/library/GroupTest.kt b/library/src/androidTest/java/org/xmtp/android/library/GroupTest.kt index dc4e3141d..0402a8754 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/GroupTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/GroupTest.kt @@ -21,17 +21,17 @@ import uniffi.xmtpv3.GroupPermissions @RunWith(AndroidJUnit4::class) class GroupTest { - lateinit var fakeApiClient: FakeApiClient - lateinit var alixWallet: PrivateKeyBuilder - lateinit var boWallet: PrivateKeyBuilder - lateinit var alix: PrivateKey - lateinit var alixClient: Client - lateinit var bo: PrivateKey - lateinit var boClient: Client - lateinit var caroWallet: PrivateKeyBuilder - lateinit var caro: PrivateKey - lateinit var caroClient: Client - lateinit var fixtures: Fixtures + private lateinit var fakeApiClient: FakeApiClient + private lateinit var alixWallet: PrivateKeyBuilder + private lateinit var boWallet: PrivateKeyBuilder + private lateinit var alix: PrivateKey + private lateinit var alixClient: Client + private lateinit var bo: PrivateKey + private lateinit var boClient: Client + private lateinit var caroWallet: PrivateKeyBuilder + private lateinit var caro: PrivateKey + private lateinit var caroClient: Client + private lateinit var fixtures: Fixtures @Before fun setUp() { @@ -99,6 +99,9 @@ class GroupTest { assert(boGroup.id.isNotEmpty()) assert(alixGroup.id.isNotEmpty()) + assertEquals(boClient.contacts.consentList.groupState(boGroup.id), ConsentState.ALLOWED) + assertEquals(alixClient.contacts.consentList.groupState(alixGroup.id), ConsentState.UNKNOWN) + boGroup.addMembers(listOf(caro.walletAddress)) runBlocking { alixGroup.sync() } assertEquals(alixGroup.memberAddresses().size, 3) @@ -239,7 +242,7 @@ class GroupTest { @Test fun testCannotSendMessageToGroupMemberNotOnV3() { - var fakeApiClient = FakeApiClient() + val fakeApiClient = FakeApiClient() val chuxAccount = PrivateKeyBuilder() val chux: PrivateKey = chuxAccount.getPrivateKey() val chuxClient: Client = Client().create(account = chuxAccount, apiClient = fakeApiClient) @@ -263,6 +266,16 @@ class GroupTest { } } + @Test + fun testGroupStartsWithAllowedState() { + val group = boClient.conversations.newGroup(listOf(alix.walletAddress)) + group.send("howdy") + group.send("gm") + runBlocking { group.sync() } + assert(boClient.contacts.isGroupAllowed(group.id)) + assertEquals(boClient.contacts.consentList.groupState(group.id), ConsentState.ALLOWED) + } + @Test fun testCanSendMessageToGroup() { val group = boClient.conversations.newGroup(listOf(alix.walletAddress)) @@ -406,4 +419,41 @@ class GroupTest { assertEquals(conversation.topic, awaitItem().topic) } } + + @Test + fun testCanAllowGroup() { + val group = boClient.conversations.newGroup( + listOf( + alix.walletAddress, + caro.walletAddress + ) + ) + + var result = boClient.contacts.isGroupAllowed(group.id) + + assert(!result) + + boClient.contacts.allowGroup(listOf(group.id)) + + result = boClient.contacts.isGroupAllowed(group.id) + assert(result) + } + + @Test + fun testCanDenyGroup() { + val group = boClient.conversations.newGroup( + listOf( + alix.walletAddress, + caro.walletAddress + ) + ) + var result = boClient.contacts.isGroupAllowed(group.id) + + assert(!result) + + boClient.contacts.denyGroup(listOf(group.id)) + + result = boClient.contacts.isGroupDenied(group.id) + assert(result) + } } diff --git a/library/src/main/java/org/xmtp/android/library/Contacts.kt b/library/src/main/java/org/xmtp/android/library/Contacts.kt index f433e6e1d..09bfa8f6e 100644 --- a/library/src/main/java/org/xmtp/android/library/Contacts.kt +++ b/library/src/main/java/org/xmtp/android/library/Contacts.kt @@ -1,5 +1,6 @@ package org.xmtp.android.library +import com.google.protobuf.kotlin.toByteStringUtf8 import kotlinx.coroutines.runBlocking import org.xmtp.android.library.messages.ContactBundle import org.xmtp.android.library.messages.ContactBundleBuilder @@ -14,7 +15,7 @@ import java.util.Date enum class ConsentState { ALLOWED, DENIED, - UNKNOWN + UNKNOWN, } data class ConsentListEntry( @@ -23,7 +24,8 @@ data class ConsentListEntry( val consentType: ConsentState, ) { enum class EntryType { - ADDRESS + ADDRESS, + GROUP_ID, } companion object { @@ -33,6 +35,13 @@ data class ConsentListEntry( ): ConsentListEntry { return ConsentListEntry(address, EntryType.ADDRESS, type) } + + fun groupId( + groupId: ByteArray, + type: ConsentState = ConsentState.UNKNOWN, + ): ConsentListEntry { + return ConsentListEntry(String(groupId), EntryType.GROUP_ID, type) + } } val key: String @@ -45,70 +54,103 @@ class ConsentList(val client: Client) { client.privateKeyBundleV1.identityKey.publicKey.secp256K1Uncompressed.bytes private val privateKey = client.privateKeyBundleV1.identityKey.secp256K1.bytes - private val identifier: String = uniffi.xmtpv3.generatePrivatePreferencesTopicIdentifier( - privateKey.toByteArray() - ) + private val identifier: String = + uniffi.xmtpv3.generatePrivatePreferencesTopicIdentifier( + privateKey.toByteArray(), + ) @OptIn(ExperimentalUnsignedTypes::class) suspend fun load(): ConsentList { - val envelopes = client.apiClient.envelopes( - Topic.preferenceList(identifier).description, - Pagination(direction = MessageApiOuterClass.SortDirection.SORT_DIRECTION_ASCENDING) - ) + val envelopes = + client.apiClient.envelopes( + Topic.preferenceList(identifier).description, + Pagination(direction = MessageApiOuterClass.SortDirection.SORT_DIRECTION_ASCENDING), + ) val consentList = ConsentList(client) val preferences: MutableList = mutableListOf() for (envelope in envelopes) { - val payload = uniffi.xmtpv3.userPreferencesDecrypt( - publicKey.toByteArray(), - privateKey.toByteArray(), - envelope.message.toByteArray() - ) + val payload = + uniffi.xmtpv3.userPreferencesDecrypt( + publicKey.toByteArray(), + privateKey.toByteArray(), + envelope.message.toByteArray(), + ) preferences.add( PrivatePreferencesAction.parseFrom( - payload.toUByteArray().toByteArray() - ) + payload.toUByteArray().toByteArray(), + ), ) } preferences.iterator().forEach { preference -> - preference.allow?.walletAddressesList?.forEach { address -> + preference.allowAddress?.walletAddressesList?.forEach { address -> consentList.allow(address) } - preference.block?.walletAddressesList?.forEach { address -> + preference.denyAddress?.walletAddressesList?.forEach { address -> consentList.deny(address) } + preference.allowGroup?.groupIdsList?.forEach { groupId -> + consentList.allowGroup(groupId.toByteArray()) + } + preference.denyGroup?.groupIdsList?.forEach { groupId -> + consentList.denyGroup(groupId.toByteArray()) + } } return consentList } fun publish(entry: ConsentListEntry) { - val payload = PrivatePreferencesAction.newBuilder().also { - when (entry.consentType) { - ConsentState.ALLOWED -> it.setAllow( - PrivatePreferencesAction.Allow.newBuilder().addWalletAddresses(entry.value) - ) - - ConsentState.DENIED -> it.setBlock( - PrivatePreferencesAction.Block.newBuilder().addWalletAddresses(entry.value) - ) - - ConsentState.UNKNOWN -> it.clearMessageType() - } - }.build() - - val message = uniffi.xmtpv3.userPreferencesEncrypt( - publicKey.toByteArray(), - privateKey.toByteArray(), - payload.toByteArray() - ) + val payload = + PrivatePreferencesAction.newBuilder().also { + when (entry.entryType) { + ConsentListEntry.EntryType.ADDRESS -> { + when (entry.consentType) { + ConsentState.ALLOWED -> + it.setAllowAddress( + PrivatePreferencesAction.AllowAddress.newBuilder().addWalletAddresses(entry.value), + ) + + ConsentState.DENIED -> + it.setDenyAddress( + PrivatePreferencesAction.DenyAddress.newBuilder().addWalletAddresses(entry.value), + ) + + ConsentState.UNKNOWN -> it.clearMessageType() + } + } + ConsentListEntry.EntryType.GROUP_ID -> { + when (entry.consentType) { + ConsentState.ALLOWED -> + it.setAllowGroup( + PrivatePreferencesAction.AllowGroup.newBuilder().addGroupIds(entry.value.toByteStringUtf8()), + ) + + ConsentState.DENIED -> + it.setDenyGroup( + PrivatePreferencesAction.DenyGroup.newBuilder().addGroupIds(entry.value.toByteStringUtf8()), + ) + + ConsentState.UNKNOWN -> it.clearMessageType() + } + } + } + }.build() + + val message = + uniffi.xmtpv3.userPreferencesEncrypt( + publicKey.toByteArray(), + privateKey.toByteArray(), + payload.toByteArray(), + ) - val envelope = EnvelopeBuilder.buildFromTopic( - Topic.preferenceList(identifier), - Date(), - ByteArray(message.size) { message[it].toByte() } - ) + val envelope = + EnvelopeBuilder.buildFromTopic( + Topic.preferenceList(identifier), + Date(), + ByteArray(message.size) { message[it] }, + ) client.publish(listOf(envelope)) } @@ -127,11 +169,31 @@ class ConsentList(val client: Client) { return entry } + fun allowGroup(groupId: ByteArray): ConsentListEntry { + val entry = ConsentListEntry.groupId(groupId, ConsentState.ALLOWED) + entries[ConsentListEntry.groupId(groupId).key] = entry + + return entry + } + + fun denyGroup(groupId: ByteArray): ConsentListEntry { + val entry = ConsentListEntry.groupId(groupId, ConsentState.DENIED) + entries[ConsentListEntry.groupId(groupId).key] = entry + + return entry + } + fun state(address: String): ConsentState { val entry = entries[ConsentListEntry.address(address).key] return entry?.consentType ?: ConsentState.UNKNOWN } + + fun groupState(groupId: ByteArray): ConsentState { + val entry = entries[ConsentListEntry.groupId(groupId).key] + + return entry?.consentType ?: ConsentState.UNKNOWN + } } data class Contacts( @@ -139,7 +201,6 @@ data class Contacts( val knownBundles: MutableMap = mutableMapOf(), val hasIntroduced: MutableMap = mutableMapOf(), ) { - var consentList: ConsentList = ConsentList(client) fun refreshConsentList(): ConsentList { @@ -149,14 +210,6 @@ data class Contacts( return consentList } - fun isAllowed(address: String): Boolean { - return consentList.state(address) == ConsentState.ALLOWED - } - - fun isDenied(address: String): Boolean { - return consentList.state(address) == ConsentState.DENIED - } - fun allow(addresses: List) { for (address in addresses) { ConsentList(client).publish(consentList.allow(address)) @@ -169,11 +222,37 @@ data class Contacts( } } - fun has(peerAddress: String): Boolean = - knownBundles[peerAddress] != null + fun allowGroup(groupIds: List) { + for (id in groupIds) { + ConsentList(client).publish(consentList.allowGroup(id)) + } + } + + fun denyGroup(groupIds: List) { + for (id in groupIds) { + ConsentList(client).publish(consentList.denyGroup(id)) + } + } + + fun isAllowed(address: String): Boolean { + return consentList.state(address) == ConsentState.ALLOWED + } + + fun isDenied(address: String): Boolean { + return consentList.state(address) == ConsentState.DENIED + } + + fun isGroupAllowed(groupId: ByteArray): Boolean { + return consentList.groupState(groupId) == ConsentState.ALLOWED + } + + fun isGroupDenied(groupId: ByteArray): Boolean { + return consentList.groupState(groupId) == ConsentState.DENIED + } + + fun has(peerAddress: String): Boolean = knownBundles[peerAddress] != null - fun needsIntroduction(peerAddress: String): Boolean = - hasIntroduced[peerAddress] != true + fun needsIntroduction(peerAddress: String): Boolean = hasIntroduced[peerAddress] != true fun find(peerAddress: String): ContactBundle? { val knownBundle = knownBundles[peerAddress] 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 bacf25064..506bc3571 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversation.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversation.kt @@ -100,7 +100,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.client.contacts.consentList.state(address = peerAddress) is V2 -> conversationV2.client.contacts.consentList.state(address = peerAddress) - is Group -> ConsentState.UNKNOWN // No such thing as consent for a group + is Group -> group.client.contacts.consentList.groupState(groupId = group.id) } } @@ -214,7 +214,7 @@ sealed class Conversation { return client.address } - // Is the topic of the conversation depending of the version + // Is the topic of the conversation depending on the version val topic: String get() { return when (this) { 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 dca862829..cec8da0de 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversations.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversations.kt @@ -115,6 +115,8 @@ data class Conversations( libXMTPConversations?.createGroup(accountAddresses, permissions = permissions) ?: throw XMTPException("Client does not support Groups") } + client.contacts.allowGroup(groupIds = listOf(group.id())) + return Group(client, group) } diff --git a/library/src/main/java/org/xmtp/android/library/Group.kt b/library/src/main/java/org/xmtp/android/library/Group.kt index 60ec6ec51..99e93c8a1 100644 --- a/library/src/main/java/org/xmtp/android/library/Group.kt +++ b/library/src/main/java/org/xmtp/android/library/Group.kt @@ -42,6 +42,9 @@ class Group(val client: Client, private val libXMTPGroup: FfiGroup) { } fun send(encodedContent: EncodedContent): String { + if (client.contacts.consentList.groupState(groupId = id) == ConsentState.UNKNOWN) { + client.contacts.allowGroup(groupIds = listOf(id)) + } runBlocking { libXMTPGroup.send(contentBytes = encodedContent.toByteArray()) } diff --git a/library/src/test/java/org/xmtp/android/library/TestHelpers.kt b/library/src/test/java/org/xmtp/android/library/TestHelpers.kt index 8ea449f9b..8ac997b1f 100644 --- a/library/src/test/java/org/xmtp/android/library/TestHelpers.kt +++ b/library/src/test/java/org/xmtp/android/library/TestHelpers.kt @@ -43,13 +43,11 @@ class FakeWallet : SigningKey { } override suspend fun sign(data: ByteArray): Signature { - val signature = privateKeyBuilder.sign(data) - return signature + return privateKeyBuilder.sign(data) } override suspend fun sign(message: String): Signature { - val signature = privateKeyBuilder.sign(message) - return signature + return privateKeyBuilder.sign(message) } override val address: String @@ -66,8 +64,8 @@ class FakeApiClient : ApiClient { override val environment: XMTPEnvironment = XMTPEnvironment.LOCAL private var authToken: String? = null private val responses: MutableMap> = mutableMapOf() - val published: MutableList = mutableListOf() - var forbiddingQueries = false + private val published: MutableList = mutableListOf() + private var forbiddingQueries = false private var stream = FakeStreamHolder() fun assertNoPublish(callback: () -> Unit) {