From 0cd7dc67c31a65bcf84873ea3ed0de0c9b429d52 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:29:12 -0400 Subject: [PATCH 1/6] feat(chat): provide the invite cipher and map its envelopes Bind DefaultInviteCipher for injection and convert invite envelopes between the transport models and the chat-cipher types. The cipher implements one scheme per message and leaves the check to the caller, so an envelope in any other scheme maps to null. FakeInviteCipher stands in for it in JVM tests, since libsodium can't load there, and rejects a bundle at the same step the real one would. --- .../flipcash/services/chat/InviteEnvelopes.kt | 24 ++++++ .../services/inject/ChatCryptoModule.kt | 5 ++ .../services/chat/FakeInviteCipher.kt | 73 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/InviteEnvelopes.kt create mode 100644 services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeInviteCipher.kt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/InviteEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/InviteEnvelopes.kt new file mode 100644 index 0000000000..5331b5785d --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/InviteEnvelopes.kt @@ -0,0 +1,24 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.chatcipher.InviteKeyEnvelope as SealedInviteKey + +/* + * Moves invite envelopes between the transport models and the chat-cipher types. The cipher + * implements one scheme per message and leaves checking it to the caller, so an envelope in any + * other scheme has no cipher form and comes back null. + */ + +fun InviteKeyEnvelope.toSealed(): SealedInviteKey? = + if (scheme == InviteKeyEnvelope.SCHEME_XCHACHA20POLY1305) SealedInviteKey(nonce, ciphertext, signature) else null + +fun SealedInviteKey.toEnvelope(): InviteKeyEnvelope = + InviteKeyEnvelope(InviteKeyEnvelope.SCHEME_XCHACHA20POLY1305, nonce, ciphertext, signature) + +fun InviteSecretEnvelope.toPayload(): EncryptedPayload? = + if (scheme == InviteSecretEnvelope.SCHEME_X25519_XCHACHA20POLY1305) EncryptedPayload(nonce, ciphertext) else null + +fun EncryptedPayload.toInviteSecretEnvelope(): InviteSecretEnvelope = + InviteSecretEnvelope(InviteSecretEnvelope.SCHEME_X25519_XCHACHA20POLY1305, nonce, ciphertext) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt index 76c4cc2d78..5d04fb7df3 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt @@ -6,6 +6,8 @@ import com.flipcash.services.chat.GroupKeyEnvelopes import com.flipcash.services.chat.GroupKeySource import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.DefaultChatCipher +import com.getcode.chatcipher.DefaultInviteCipher +import com.getcode.chatcipher.InviteCipher import dagger.Binds import dagger.Module import dagger.Provides @@ -25,5 +27,8 @@ internal abstract class ChatCryptoModule { companion object { @Provides fun provideChatCipher(): ChatCipher = DefaultChatCipher + + @Provides + fun provideInviteCipher(): InviteCipher = DefaultInviteCipher } } diff --git a/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeInviteCipher.kt b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeInviteCipher.kt new file mode 100644 index 0000000000..a84c604188 --- /dev/null +++ b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeInviteCipher.kt @@ -0,0 +1,73 @@ +package com.flipcash.services.chat + +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.chatcipher.InviteBundleFailure +import com.getcode.chatcipher.InviteBundleResult +import com.getcode.chatcipher.InviteCipher +import com.getcode.chatcipher.InviteKeyEnvelope +import com.getcode.ed25519kmp.KeyPair + +/** + * Stands in for [com.getcode.chatcipher.DefaultInviteCipher], whose libsodium binding can't load + * on a JVM host. Envelopes carry what sealed them in the clear, so opening fails unless the + * secret, chat, invite and keys all match, and [openInviteKeyBundle] rejects at the same step + * the real one would. + */ +class FakeInviteCipher : InviteCipher { + private var minted = 0 + + /** Each call returns a different secret: the n-th is all bytes n. */ + override fun newSecret(): ByteArray = ByteArray(32) { (minted + 1).toByte() }.also { minted++ } + + override fun inviteToken(secret: ByteArray): ByteArray = ByteArray(32) { (secret[it].toInt() xor 0x5a).toByte() } + + override fun inviteId(secret: ByteArray): ByteArray = inviteToken(secret).copyOf(16) + + override fun sealInviteKeyEnvelope(inviter: KeyPair, secret: ByteArray, chatId: ByteArray, chatKey: ByteArray) = + InviteKeyEnvelope( + nonce = ByteArray(24), + ciphertext = secret + chatId + chatKey, + signature = inviter.publicKey + ByteArray(32), + ) + + override fun openInviteKeyEnvelope(secret: ByteArray, chatId: ByteArray, inviterPk: ByteArray, envelope: InviteKeyEnvelope): ByteArray { + if (!envelope.signature.copyOf(32).contentEquals(inviterPk)) throw ChatCipherException("signature does not verify") + return openWithHeader(envelope.ciphertext, secret + chatId) + } + + override fun sealInviteSecretEnvelope(owner: KeyPair, secret: ByteArray, chatId: ByteArray, inviteId: ByteArray) = + EncryptedPayload(nonce = ByteArray(24), ciphertext = owner.publicKey + chatId + inviteId + secret) + + override fun openInviteSecretEnvelope(owner: KeyPair, chatId: ByteArray, inviteId: ByteArray, envelope: EncryptedPayload): ByteArray = + openWithHeader(envelope.ciphertext, owner.publicKey + chatId + inviteId) + + override fun openInviteKeyBundle( + member: KeyPair, + creatorPk: ByteArray, + wrappedByCreator: Boolean, + chatId: ByteArray, + inviteId: ByteArray, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: EncryptedPayload, + ): InviteBundleResult { + if (!wrappedByCreator) return InviteBundleResult.Rejected(InviteBundleFailure.WrappedBy) + if (!keyEnvelope.signature.copyOf(32).contentEquals(creatorPk)) { + return InviteBundleResult.Rejected(InviteBundleFailure.Signature) + } + val secret = runCatching { openInviteSecretEnvelope(member, chatId, inviteId, secretEnvelope) }.getOrNull() + if (secret == null || !inviteId(secret).contentEquals(inviteId)) { + return InviteBundleResult.Rejected(InviteBundleFailure.SecretOpen) + } + val chatKey = runCatching { openWithHeader(keyEnvelope.ciphertext, secret + chatId) }.getOrNull() + ?: return InviteBundleResult.Rejected(InviteBundleFailure.KeyOpen) + return InviteBundleResult.Opened(chatKey) + } + + private fun openWithHeader(sealed: ByteArray, header: ByteArray): ByteArray { + if (sealed.size < header.size || !sealed.copyOfRange(0, header.size).contentEquals(header)) { + throw ChatCipherException("authentication failed") + } + return sealed.copyOfRange(header.size, sealed.size) + } +} From 7367f6b519de3da0f56e584cf320ab7e655c4c53 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:32:28 -0400 Subject: [PATCH 2/6] feat(chat): open the invite key bundle for invite-joined members A member who joined with an invite link gets GetKeyEnvelope's invite_key_bundle until they store their own envelope, and GroupKeyEnvelopes returned it as a failure, so that member never had a key. It now opens the bundle with InviteCipher.openInviteKeyBundle, wraps the chat key for this device and calls SetKeyEnvelope, the same way it handles an envelope the creator wrapped. The creator's key comes from the Resolver, falling back to the wrapped_by_public_key the server reports. A bundle that fails a check, or uses a scheme this build doesn't know, marks the chat in WrongKeyChats and returns UntrustedEnvelope, so messages show as undecryptable instead of waiting for a key. Other failures stay uncached, so opening the chat retries the fetch. --- .../services/chat/GroupKeyEnvelopes.kt | 62 ++++++++- .../flipcash/services/chat/WrongKeyChats.kt | 36 ++++++ .../services/chat/GroupKeyEnvelopesTest.kt | 118 +++++++++++++++++- .../services/chat/WrongKeyChatsTest.kt | 36 ++++++ 4 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/chat/WrongKeyChatsTest.kt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt index 594726e814..5f0cecf23b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -4,9 +4,12 @@ import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.InviteKeyBundle import com.flipcash.services.models.chat.KeyEnvelope import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.EncryptedPayload +import com.getcode.chatcipher.InviteBundleResult +import com.getcode.chatcipher.InviteCipher import com.getcode.ed25519kmp.KeyPair import com.getcode.opencode.model.core.ID import kotlinx.coroutines.sync.Mutex @@ -25,8 +28,10 @@ import javax.inject.Singleton @Singleton class GroupKeyEnvelopes @Inject constructor( private val cipher: ChatCipher, + private val inviteCipher: InviteCipher, private val keys: ChatKeySource, private val chatController: ChatController, + private val wrongKeyChats: WrongKeyChats, ) : GroupKeySource { // Own public key in the key so a different account on this device never reuses a group key. private data class CacheKey(val chatId: ID, val ownPk: ID) @@ -74,6 +79,11 @@ class GroupKeyEnvelopes @Inject constructor( * * `NO_ENVELOPE` means a creator whose setup stopped before [storeNewKey] landed; only the * creator regenerates the key there. + * + * A member who joined with an invite link gets the invite's copy of the key until they store + * their own envelope. It is opened and re-wrapped the same way; a copy that fails a check marks + * the chat in [WrongKeyChats]. A fetch that fails for any other reason is not cached, so the + * next call, such as opening the chat, tries again. */ override suspend fun key(chatId: ChatId): Result { val own = keys.ownKeyPair() @@ -84,7 +94,10 @@ class GroupKeyEnvelopes @Inject constructor( return locks.getOrPut(cacheKey) { Mutex() }.withLock { groupKeys[cacheKey]?.let { return@withLock Result.success(it) } fetchKey(chatId, own) - .onSuccess { groupKeys[cacheKey] = it } + .onSuccess { + groupKeys[cacheKey] = it + wrongKeyChats.clear(chatId) + } } } @@ -97,6 +110,9 @@ class GroupKeyEnvelopes @Inject constructor( ?: fetchKey(chatId, own, retried = true).getOrThrow() } } + if (error is GetKeyEnvelopeError.InviteKeyBundleOnly) { + return openInviteBundle(chatId, own, error.bundle, retried) + } return Result.failure(error) } if (stored.envelope.scheme != KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305) { @@ -117,7 +133,51 @@ class GroupKeyEnvelopes @Inject constructor( val creatorPk = keys.peerPublicKey(creator).getOrElse { return Result.failure(it) } val groupKey = runCatching { cipher.unwrapGroupKey(own, creatorPk, chatId.bytes, payload) } .getOrElse { return Result.failure(UntrustedEnvelope("Creator-wrapped envelope did not open", it)) } + return storeSelfCopy(chatId, own, groupKey, retried) + } + + /** + * Opens the invite's copy of the key in the order the `InviteKeyEnvelope` comment sets. The + * creator's key comes from the Resolver like a creator-wrapped envelope's, and falls back to the + * `wrapped_by_public_key` the server reports when the Resolver can't answer. + */ + private suspend fun openInviteBundle( + chatId: ChatId, + own: KeyPair, + bundle: InviteKeyBundle, + retried: Boolean, + ): Result { + val creator = chatController.getChat(chatId).getOrElse { return Result.failure(it) }.creator + val creatorPk = creator?.let { keys.peerPublicKey(it) }?.getOrNull() + ?: bundle.wrappedByPublicKey?.byteArray + ?: return Result.failure(IllegalStateException("No public key for the invite's creator")) + val keyEnvelope = bundle.keyEnvelope.toSealed() + val secretEnvelope = bundle.secretEnvelope.toPayload() + if (keyEnvelope == null || secretEnvelope == null) { + return wrongKey(chatId, "Unsupported invite envelope scheme") + } + + val opened = inviteCipher.openInviteKeyBundle( + member = own, + creatorPk = creatorPk, + wrappedByCreator = creator != null && bundle.wrappedBy == creator, + chatId = chatId.bytes, + inviteId = bundle.inviteId.bytes, + keyEnvelope = keyEnvelope, + secretEnvelope = secretEnvelope, + ) + return when (opened) { + is InviteBundleResult.Opened -> storeSelfCopy(chatId, own, opened.chatKey, retried) + is InviteBundleResult.Rejected -> wrongKey(chatId, "Invite key bundle rejected at ${opened.failsAt}") + } + } + + private fun wrongKey(chatId: ChatId, reason: String): Result { + wrongKeyChats.mark(chatId) + return Result.failure(UntrustedEnvelope(reason)) + } + private suspend fun storeSelfCopy(chatId: ChatId, own: KeyPair, groupKey: ByteArray, retried: Boolean): Result { // The self-wrapped copy only saves the next launch a trip through the creator's envelope, so // failing to store it doesn't cost the key that just opened. val selfWrapped = runCatching { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt new file mode 100644 index 0000000000..21013d8f08 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt @@ -0,0 +1,36 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Private groups whose invite key bundle failed a check: the member joined, but the copy of the + * chat key their link carried is not one they can trust. The chat shows the wrong-key state and + * must not send. The contract's only repair is to leave and be admitted from the lobby + * (`KeyEnvelope` comment), so nothing here retries. + * + * [GroupKeyEnvelopes] writes it; a chat whose key later opens drops out. Held in memory only: the + * next launch fetches the bundle again and lands here again if it still fails. + */ +@Singleton +class WrongKeyChats @Inject constructor() { + private val wrong = MutableStateFlow>(emptySet()) + + val chats: StateFlow> = wrong.asStateFlow() + + fun observe(chatId: ChatId): Flow = wrong.map { chatId in it }.distinctUntilChanged() + + internal fun mark(chatId: ChatId) = wrong.update { it + chatId } + + internal fun clear(chatId: ChatId) { + if (chatId in wrong.value) wrong.update { it - chatId } + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt index 8ce70c32b2..48279b17ee 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -5,6 +5,9 @@ import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyBundle +import com.flipcash.services.models.chat.InviteSecretEnvelope import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.StoredKeyEnvelope import com.getcode.chatcipher.EncryptedPayload @@ -20,6 +23,7 @@ import org.junit.Test import java.io.IOException import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class GroupKeyEnvelopesTest { @@ -33,7 +37,7 @@ class GroupKeyEnvelopesTest { override suspend fun peerPublicKey(userId: ID): Result = error("unused") } private val controller = mockk() - private val envelopes = GroupKeyEnvelopes(FakeChatCipher, keys, controller) + private val envelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), keys, controller, WrongKeyChats()) @Test fun `storeNewKey stores a self-wrapped envelope`() = runTest { @@ -96,13 +100,15 @@ class GroupKeyEnvelopesTest { private val creatorId: ID = List(16) { 1 } private val memberId: ID = List(16) { 2 } private val memberKeys = KeyPair(publicKey = memberPk, privateKey = ByteArray(64) { 22 }) + private val inviteCipher = FakeInviteCipher() + private val wrongKeyChats = WrongKeyChats() private val memberSource = object : ChatKeySource { override fun ownKeyPair() = memberKeys override fun ownUserId() = memberId override suspend fun peerPublicKey(userId: ID): Result = if (userId == creatorId) Result.success(creatorKeys.publicKey) else Result.failure(IOException("unknown")) } - private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, memberSource, controller) + private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, memberSource, controller, wrongKeyChats) private val groupKey = ByteArray(32) { 5 } private fun stored(wrapper: KeyPair, recipient: ByteArray, wrappedBy: ID?): StoredKeyEnvelope { @@ -193,11 +199,117 @@ class GroupKeyEnvelopesTest { override fun ownUserId() = creatorId override suspend fun peerPublicKey(userId: ID): Result = error("unused") } - val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, creatorSource, controller) + val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), creatorSource, controller, WrongKeyChats()) coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) assertContentEquals(FakeChatCipher.newGroupKey(), creatorEnvelopes.key(chatId).getOrThrow()) } + + // Invite path: the member joined with a link and the server holds only the invite's copy. + + private val secret = ByteArray(32) { 3 } + private val inviteId = InviteId(inviteCipher.inviteId(secret)) + + private fun bundle( + signer: KeyPair = creatorKeys, + wrappedBy: ID? = creatorId, + secretOwner: KeyPair = memberKeys, + sealedFor: ByteArray = secret, + secretScheme: Int = InviteSecretEnvelope.SCHEME_X25519_XCHACHA20POLY1305, + ): GetKeyEnvelopeError.InviteKeyBundleOnly { + val copy = inviteCipher.sealInviteKeyEnvelope(signer, sealedFor, chatId.bytes, groupKey).toEnvelope() + val own = inviteCipher.sealInviteSecretEnvelope(secretOwner, secret, chatId.bytes, inviteId.bytes) + return GetKeyEnvelopeError.InviteKeyBundleOnly( + InviteKeyBundle( + inviteId = inviteId, + keyEnvelope = copy, + secretEnvelope = InviteSecretEnvelope(secretScheme, own.nonce, own.ciphertext), + wrappedBy = wrappedBy, + wrappedByPublicKey = null, + ), + ) + } + + private fun assertWrongKey(error: GetKeyEnvelopeError.InviteKeyBundleOnly) = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(error) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + assertTrue(chatId in wrongKeyChats.chats.value) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `an invite bundle opens and the member stores a self-wrapped copy`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + val rewrapped = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(rewrapped)) } returns Result.success(Unit) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + + assertContentEquals(memberPk + memberPk + groupKey, rewrapped.captured.ciphertext) + assertFalse(chatId in wrongKeyChats.chats.value) + } + + @Test + fun `a failed store after the bundle opens still returns the key`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(IOException("offline")) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + } + + @Test + fun `a bundle wrapped by someone other than the creator is the wrong key`() = + assertWrongKey(bundle(wrappedBy = List(16) { 9 })) + + @Test + fun `a bundle not signed by the creator is the wrong key`() = + assertWrongKey(bundle(signer = KeyPair(publicKey = ByteArray(32) { 99.toByte() }, privateKey = ByteArray(64)))) + + @Test + fun `a bundle whose secret envelope does not open is the wrong key`() = + assertWrongKey(bundle(secretOwner = KeyPair(publicKey = ByteArray(32) { 77 }, privateKey = ByteArray(64)))) + + @Test + fun `a bundle whose copy does not open is the wrong key`() = + assertWrongKey(bundle(sealedFor = ByteArray(32) { 4 })) + + @Test + fun `a bundle in an unknown scheme is the wrong key`() = assertWrongKey(bundle(secretScheme = 9)) + + @Test + fun `a network failure is not cached and the next open retries`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.failure(GetKeyEnvelopeError.Other(IOException("offline"))), + Result.failure(bundle()), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertFalse(chatId in wrongKeyChats.chats.value) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `a key that opens later clears the wrong-key mark`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.failure(bundle(wrappedBy = List(16) { 9 })), + Result.success(stored(memberKeys, memberPk, memberId)), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertTrue(chatId in wrongKeyChats.chats.value) + + memberEnvelopes.key(chatId).getOrThrow() + assertFalse(chatId in wrongKeyChats.chats.value) + } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/WrongKeyChatsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/WrongKeyChatsTest.kt new file mode 100644 index 0000000000..6d0002e981 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/WrongKeyChatsTest.kt @@ -0,0 +1,36 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WrongKeyChatsTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val other = ChatId(ByteArray(32) { 8 }) + private val chats = WrongKeyChats() + + @Test + fun `a marked chat is observed until it is cleared`() = runTest { + assertFalse(chats.observe(chatId).first()) + + chats.mark(chatId) + assertTrue(chats.observe(chatId).first()) + assertFalse(chats.observe(other).first()) + + chats.clear(chatId) + assertFalse(chats.observe(chatId).first()) + } + + @Test + fun `marking twice keeps one entry`() { + chats.mark(chatId) + chats.mark(ChatId(ByteArray(32) { 7 })) + + assertEquals(setOf(chatId), chats.chats.value) + } +} From 0d2904a6250b62205f0c78f8d01aaab0949356ca Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:37:28 -0400 Subject: [PATCH 3/6] feat(persistence): store the invite grant ViewerState.Permissions.canInvite arrived with flipcash2-client-protocol 0.22.0, but chat_metadata had no column for it, so a host lost the grant whenever a chat loaded from the database and the invite entry points couldn't be gated on it. v46 adds chat_metadata.can_invite (default 0), written and cleared alongside can_edit. Migration45To46 zeroes viewer_state_version for the reason Migration36To37 gives: the next fetch reports the version the row already holds, and without the re-arm the gate drops it and the grant stays 0 on every chat that predates the upgrade. Also adds a round-trip test for MemberJoined's invitedBy, which the System JSON already carries. --- .../46.json | 1062 +++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 14 +- .../app/persistence/dao/ChatMetadataDao.kt | 6 +- .../entities/ChatMetadataEntity.kt | 3 + .../persistence/InviteGrantMigrationTest.kt | 72 ++ .../persistence/dao/ChatMetadataDaoTest.kt | 57 +- .../sources/ChatMetadataDataSource.kt | 1 + .../sources/mapper/chat/ChatEntityMapper.kt | 7 +- .../mapper/chat/ChatEntityMapperTest.kt | 39 + 9 files changed, 1245 insertions(+), 16 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/46.json create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/InviteGrantMigrationTest.kt diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/46.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/46.json new file mode 100644 index 0000000000..f8b67dc548 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/46.json @@ -0,0 +1,1062 @@ +{ + "formatVersion": 1, + "database": { + "version": 46, + "identityHash": "e239ef9b85f5c4def303c6a390aea283", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e239ef9b85f5c4def303c6a390aea283')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 309f74ac3d..2440f82a44 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -126,8 +126,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 42, to = 43), // pending_media table AutoMigration(from = 43, to = 44), // chat_metadata.cover_picture_json (nullable) AutoMigration(from = 44, to = 45), // chat_metadata.is_private (default 0) + AutoMigration(from = 45, to = 46, spec = FlipcashDatabase.Migration45To46::class), // chat_metadata.can_invite (default 0) ], - version = 45, + version = 46, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -296,6 +297,17 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Re-arms the viewer-state gate for the new `can_invite` column, for the reason + * [Migration36To37] gives for `can_edit`: without it a host's invite entry points stay hidden + * on every private group created before the upgrade until its viewer state version moves. + */ + class Migration45To46 : AutoMigrationSpec { + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL(Migration36To37.REARM_VIEWER_STATE) + } + } + /** * Adds `chat_messages.ciphertext_json` and `encryption_state`. A row stored before this version * with encrypted content was never opened, so it's marked for opening with its ciphertext diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index b050d9356d..7a18b0687a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -165,7 +165,7 @@ interface ChatMetadataDao { @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + "mute_forever = :muteForever, viewer_state_version = :version, " + - "can_edit = :canEdit " + + "can_edit = :canEdit, can_invite = :canInvite " + "WHERE chat_id_hex = :chatIdHex AND (:version > viewer_state_version OR viewer_state_version = 0)" ) suspend fun updateViewerStateIfNewer( @@ -173,6 +173,7 @@ interface ChatMetadataDao { muteUntilEpochMs: Long?, muteForever: Boolean, canEdit: Boolean, + canInvite: Boolean, version: Long, ) @@ -189,7 +190,7 @@ interface ChatMetadataDao { */ @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " + - "viewer_state_version = 0, can_edit = 0 WHERE chat_id_hex = :chatIdHex" + "viewer_state_version = 0, can_edit = 0, can_invite = 0 WHERE chat_id_hex = :chatIdHex" ) suspend fun clearViewerState(chatIdHex: String) @@ -232,6 +233,7 @@ interface ChatMetadataDao { muteUntilEpochMs = entity.muteUntilEpochMs, muteForever = entity.muteForever, canEdit = entity.canEdit, + canInvite = entity.canInvite, version = entity.viewerStateVersion, ) } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index 882e208ef7..1642daf28d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -65,6 +65,9 @@ data class ChatMetadataEntity( // ViewerState.Permissions — a column no write has reached yet denies rather than grants. @ColumnInfo(name = "can_edit", defaultValue = "0") val canEdit: Boolean = false, + // Private group creator only. Same storage rules as can_edit. + @ColumnInfo(name = "can_invite", defaultValue = "0") + val canInvite: Boolean = false, // Group creator; null for DMs and for group chats reconstructed without a server round // trip. Stored hex-encoded, matching every other user id column in this database. @ColumnInfo(name = "creator_hex") diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/InviteGrantMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/InviteGrantMigrationTest.kt new file mode 100644 index 0000000000..c1190cb3c4 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/InviteGrantMigrationTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.persistence + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteConnection +import androidx.test.core.app.ApplicationProvider +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * Covers the v46 upgrade adding `chat_metadata.can_invite`. + * + * Room's auto-migration adds the column at 0. [FlipcashDatabase.Migration45To46] then zeroes every + * `viewer_state_version`, so the next fetch, which reports the version the row already holds, gets + * past the write gate and fills the grant. + */ +@RunWith(RobolectricTestRunner::class) +class InviteGrantMigrationTest { + + private val context = ApplicationProvider.getApplicationContext() + + private fun openV46Shape(): SupportSQLiteDatabase { + val callback = object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + } + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) // in-memory + .callback(callback) + .build() + val db = FrameworkSQLiteOpenHelperFactory().create(config).writableDatabase + // The columns the spec touches, as the auto-migration leaves them. + db.execSQL( + "CREATE TABLE chat_metadata (chat_id_hex TEXT NOT NULL, " + + "mute_forever INTEGER NOT NULL DEFAULT 0, " + + "viewer_state_version INTEGER NOT NULL DEFAULT 0, " + + "can_edit INTEGER NOT NULL DEFAULT 0, " + + "can_invite INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(chat_id_hex))" + ) + return db + } + + private fun SupportSQLiteDatabase.long(sql: String): Long = + query(sql).use { it.moveToFirst(); it.getLong(0) } + + @Test + fun `migration 45 to 46 re-arms the viewer state gate and keeps the mute`() { + val db = openV46Shape() + db.execSQL( + "INSERT INTO chat_metadata (chat_id_hex, mute_forever, viewer_state_version, can_edit) VALUES (?,?,?,?)", + arrayOf("aa", 1, 7, 1), + ) + db.execSQL( + "INSERT INTO chat_metadata (chat_id_hex, viewer_state_version) VALUES (?,?)", + arrayOf("bb", 3), + ) + + FlipcashDatabase.Migration45To46().onPostMigrate(SupportSQLiteConnection(db)) + + assertEquals(0L, db.long("SELECT MAX(viewer_state_version) FROM chat_metadata")) + assertEquals(0L, db.long("SELECT SUM(can_invite) FROM chat_metadata")) + // Only the version is reset; state already stored stays until the fetch replaces it. + assertEquals(1L, db.long("SELECT mute_forever FROM chat_metadata WHERE chat_id_hex = 'aa'")) + assertEquals(1L, db.long("SELECT can_edit FROM chat_metadata WHERE chat_id_hex = 'aa'")) + + db.close() + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 9b690d83c4..782befc9e6 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -65,6 +65,7 @@ class ChatMetadataDaoTest { muteForever: Boolean = false, viewerStateVersion: Long = 0, canEdit: Boolean = false, + canInvite: Boolean = false, useE2ee: Boolean = false, isPrivate: Boolean = false, ) = ChatMetadataEntity( @@ -88,6 +89,7 @@ class ChatMetadataDaoTest { muteForever = muteForever, viewerStateVersion = viewerStateVersion, canEdit = canEdit, + canInvite = canInvite, useE2ee = useE2ee, isPrivate = isPrivate, ) @@ -351,6 +353,7 @@ class ChatMetadataDaoTest { muteUntilEpochMs = null, muteForever = false, canEdit = false, + canInvite = false, version = 5, ) @@ -368,10 +371,10 @@ class ChatMetadataDaoTest { @Test fun `an older viewer state version leaves the mute alone`() = runTest { dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 5) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, canInvite = false, version = 5) // The mute this supersedes, arriving late. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, canEdit = false, version = 4) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, canEdit = false, canInvite = false, version = 4) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertEquals(true, stored.muteForever) @@ -394,6 +397,7 @@ class ChatMetadataDaoTest { muteUntilEpochMs = null, muteForever = false, canEdit = true, + canInvite = false, version = 0, ) @@ -407,9 +411,9 @@ class ChatMetadataDaoTest { @Test fun `a viewer state at version zero cannot overwrite a real one`() = runTest { dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 3) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 3) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 0) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = false, version = 0) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertEquals(true, stored.canEdit) @@ -433,7 +437,7 @@ class ChatMetadataDaoTest { fun `switching to an indefinite mute clears the deadline`() = runTest { dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 1)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 2) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, canInvite = false, version = 2) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertNull(stored.muteUntilEpochMs) @@ -457,7 +461,7 @@ class ChatMetadataDaoTest { assertEquals(0L, stored.viewerStateVersion) // A rejoin's first state numbers from the bottom again, and must still apply. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, canEdit = false, version = 1) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, canEdit = false, canInvite = false, version = 1) assertEquals(9_000L, dao.getById(CHAT_HEX)?.muteUntilEpochMs) } @@ -468,10 +472,10 @@ class ChatMetadataDaoTest { @Test fun `an older viewer state leaves the edit grant alone`() = runTest { dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 4)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 5) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 5) // The withdrawal this supersedes, arriving late. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 3) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = false, version = 3) assertEquals(true, dao.getById(CHAT_HEX)?.canEdit) } @@ -484,7 +488,7 @@ class ChatMetadataDaoTest { fun `a newer viewer state withdraws the edit grant`() = runTest { dao.upsert(entity(chatType = "GROUP", canEdit = true, viewerStateVersion = 4)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 5) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = false, version = 5) assertEquals(false, dao.getById(CHAT_HEX)?.canEdit) } @@ -511,11 +515,44 @@ class ChatMetadataDaoTest { db.openHelper.writableDatabase.execSQL(FlipcashDatabase.Migration36To37.REARM_VIEWER_STATE) // The version the row already held, which the gate would otherwise drop. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 7) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 7) assertEquals(true, dao.getById(CHAT_HEX)?.canEdit) } + @Test + fun `a newer viewer state grants and withdraws the invite right`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 4)) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = true, version = 5) + assertEquals(true, dao.getById(CHAT_HEX)?.canInvite) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 6) + assertEquals(false, dao.getById(CHAT_HEX)?.canInvite) + } + + /** A chat the user has left grants nothing, invite right included. */ + @Test + fun `clearing the viewer state drops the invite grant`() = runTest { + dao.upsert(entity(chatType = "GROUP", canInvite = true, viewerStateVersion = 7)) + + dao.clearViewerState(CHAT_HEX) + + assertEquals(false, dao.getById(CHAT_HEX)?.canInvite) + } + + /** Same reason as the v37 re-arm, for the invite right added at v46. */ + @Test + fun `the v46 re-arm lets the next fetch fill the invite grant`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 7)) + + db.openHelper.writableDatabase.execSQL(FlipcashDatabase.Migration36To37.REARM_VIEWER_STATE) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = true, version = 7) + + assertEquals(true, dao.getById(CHAT_HEX)?.canInvite) + } + @Test fun `the viewer state version reads back, and is null for a chat that is not there`() = runTest { dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 2db45e47a3..b0b5941b13 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -109,6 +109,7 @@ class ChatMetadataDataSource @Inject constructor( muteUntilEpochMs = mapper.muteUntilEpochMs(viewerState.mute), muteForever = mapper.isMuteForever(viewerState.mute), canEdit = viewerState.permissions.canEdit, + canInvite = viewerState.permissions.canInvite, version = viewerState.version, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 6fe287176f..c4ca8636a1 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -98,6 +98,7 @@ class ChatEntityMapper @Inject constructor() { muteForever = isMuteForever(metadata.viewerState?.mute), viewerStateVersion = metadata.viewerState?.version ?: 0, canEdit = metadata.viewerState?.permissions?.canEdit ?: false, + canInvite = metadata.viewerState?.permissions?.canInvite ?: false, creatorHex = metadata.creator?.hexEncodedString(), useE2ee = metadata.useE2ee, isPrivate = metadata.isPrivate, @@ -153,7 +154,7 @@ class ChatEntityMapper @Inject constructor() { * the domain model disagree about what is stored, and a later write gate compare against a * version whose state had been silently rewritten. * - * A granted `can_edit` counts as state held about the viewer, so it keeps the row out of the + * A granted `can_edit` or `can_invite` counts as state held about the viewer, so it keeps the row out of the * null case on its own: a chat nobody has muted still has to report that the viewer may edit * it, and returning null there would deny an edit the server allows. */ @@ -165,11 +166,11 @@ class ChatEntityMapper @Inject constructor() { until != null -> MuteState.Until(Instant.fromEpochMilliseconds(until)) else -> null } - if (mute == null && viewerStateVersion == 0L && !canEdit) return null + if (mute == null && viewerStateVersion == 0L && !canEdit && !canInvite) return null return ViewerState( mute = mute, version = viewerStateVersion, - permissions = ViewerState.Permissions(canEdit = canEdit), + permissions = ViewerState.Permissions(canEdit = canEdit, canInvite = canInvite), ) } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index a9db3a7393..ae23378044 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -133,6 +133,31 @@ class ChatEntityMapperTest { assertEquals(false, entity(MessageContent.Text("still here")).isDeleted) } + /** The 5b–5d copy names the inviter, so a stored join has to keep who invited them. */ + @Test + fun `an invite join keeps its inviter through the round trip`() { + val joined = MessageContent.System( + fallbackText = "joined", + event = MessageContent.SystemEvent.UserJoined( + userId = listOf(0x01.toByte()), + admittedBy = null, + invitedBy = listOf(0x02.toByte()), + ), + ) + val entity = mapper.toEntity( + CHAT_HEX, + ChatMessage( + messageId = 1, + senderId = listOf(0xAB.toByte()), + content = listOf(joined), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 1, + ), + ) + + assertEquals(joined, mapper.toMessage(entity).content.single()) + } + @Test fun `a denied row maps to a denied delivery`() { val entity = mapper.toEntity( @@ -545,6 +570,20 @@ class ChatEntityMapperTest { assertEquals(true, restored.viewerState?.permissions?.canEdit) } + @Test + fun `an invite grant survives the round trip`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState(permissions = ViewerState.Permissions(canInvite = true)) + ) + ) + assertEquals(true, entity.canInvite) + + val restored = mapper.toMetadata(entity, emptyList(), null) + + assertEquals(true, restored.viewerState?.permissions?.canInvite) + } + // region Reactions @Test From dfcf40451cbc574549bdec1d356c9a01048a2f0b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:04:05 -0400 Subject: [PATCH 4/6] fix(chat): refuse an unknown invite scheme without the wrong-key mark An invite envelope in a scheme this build doesn't implement says nothing about whether the key is right, so it now fails as UntrustedEnvelope the way an unsupported KeyEnvelope does, and the chat isn't shown as wrong-key. Adds tests for the fallback to wrapped_by_public_key when the creator's key can't be looked up, ALREADY_SET after an invite bundle opens, and a rejected bundle being fetched again on the next open. --- .../services/chat/GroupKeyEnvelopes.kt | 4 +- .../services/chat/GroupKeyEnvelopesTest.kt | 52 ++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt index 5f0cecf23b..2b701a1f2f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -153,8 +153,10 @@ class GroupKeyEnvelopes @Inject constructor( ?: return Result.failure(IllegalStateException("No public key for the invite's creator")) val keyEnvelope = bundle.keyEnvelope.toSealed() val secretEnvelope = bundle.secretEnvelope.toPayload() + // A scheme this build doesn't implement says nothing about the key, so it is refused the + // way an unsupported KeyEnvelope is, without the wrong-key mark. if (keyEnvelope == null || secretEnvelope == null) { - return wrongKey(chatId, "Unsupported invite envelope scheme") + return Result.failure(UntrustedEnvelope("Unsupported invite envelope scheme")) } val opened = inviteCipher.openInviteKeyBundle( diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt index 48279b17ee..1cb3465d79 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -12,6 +12,7 @@ import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.StoredKeyEnvelope import com.getcode.chatcipher.EncryptedPayload import com.getcode.ed25519kmp.KeyPair +import com.getcode.solana.keys.PublicKey import com.getcode.opencode.model.core.ID import io.mockk.coEvery import io.mockk.coVerify @@ -218,6 +219,7 @@ class GroupKeyEnvelopesTest { secretOwner: KeyPair = memberKeys, sealedFor: ByteArray = secret, secretScheme: Int = InviteSecretEnvelope.SCHEME_X25519_XCHACHA20POLY1305, + wrappedByPublicKey: PublicKey? = null, ): GetKeyEnvelopeError.InviteKeyBundleOnly { val copy = inviteCipher.sealInviteKeyEnvelope(signer, sealedFor, chatId.bytes, groupKey).toEnvelope() val own = inviteCipher.sealInviteSecretEnvelope(secretOwner, secret, chatId.bytes, inviteId.bytes) @@ -227,7 +229,7 @@ class GroupKeyEnvelopesTest { keyEnvelope = copy, secretEnvelope = InviteSecretEnvelope(secretScheme, own.nonce, own.ciphertext), wrappedBy = wrappedBy, - wrappedByPublicKey = null, + wrappedByPublicKey = wrappedByPublicKey, ), ) } @@ -280,7 +282,53 @@ class GroupKeyEnvelopesTest { assertWrongKey(bundle(sealedFor = ByteArray(32) { 4 })) @Test - fun `a bundle in an unknown scheme is the wrong key`() = assertWrongKey(bundle(secretScheme = 9)) + fun `a bundle in an unknown scheme is refused without the wrong-key mark`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle(secretScheme = 9)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + assertFalse(chatId in wrongKeyChats.chats.value) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `the reported creator key opens the bundle when the creator's key can't be looked up`() = runTest { + val offline = object : ChatKeySource { + override fun ownKeyPair() = memberKeys + override fun ownUserId() = memberId + override suspend fun peerPublicKey(userId: ID): Result = Result.failure(IOException("offline")) + } + val envelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, offline, controller, wrongKeyChats) + coEvery { controller.getKeyEnvelope(chatId) } returns + Result.failure(bundle(wrappedByPublicKey = PublicKey(creatorKeys.publicKey.toList()))) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + + assertContentEquals(groupKey, envelopes.key(chatId).getOrThrow()) + } + + @Test + fun `ALREADY_SET after the bundle opens reads the envelope another device stored`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.failure(bundle()), + Result.success(stored(memberKeys, memberPk, memberId)), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(SetKeyEnvelopeError.AlreadySet()) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `a rejected bundle is not cached and is fetched again on the next open`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle(wrappedBy = List(16) { 9 })) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertTrue(memberEnvelopes.key(chatId).isFailure) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } @Test fun `a network failure is not cached and the next open retries`() = runTest { From 5c6d975bfcb79516bf2fbfaaeda4ce2b0fa92a2d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:04:06 -0400 Subject: [PATCH 5/6] fix(auth): clear wrong-key chats on logout WrongKeyChats is keyed by chat id alone, so a chat marked under one account stayed marked for the next account to sign in on the device. --- .../src/main/kotlin/com/flipcash/app/auth/AuthManager.kt | 3 +++ .../test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt | 5 +++++ .../kotlin/com/flipcash/services/chat/WrongKeyChats.kt | 7 ++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index 9d2577164c..38dca35831 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -14,6 +14,7 @@ import com.flipcash.app.push.PushTokenProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.shared.profile.ProfileCoordinator +import com.flipcash.services.chat.WrongKeyChats import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController @@ -60,6 +61,7 @@ class AuthManager @Inject constructor( private val profileCoordinator: ProfileCoordinator, private val contactCoordinator: ContactCoordinator, private val chatCoordinator: ChatCoordinator, + private val wrongKeyChats: WrongKeyChats, private val networkObserver: NetworkConnectivityListener, private val dispatchers: DispatcherProvider, // private val analytics: AnalyticsService, @@ -440,6 +442,7 @@ class AuthManager @Inject constructor( userFlags.clearAll() profileCoordinator.reset() userFlags.resetCache() + wrongKeyChats.reset() if (!BuildConfig.DEBUG) TraceManager.userId = null } diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt index 570f619337..d25e50198e 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt @@ -15,6 +15,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.profile.ProfileCoordinator +import com.flipcash.services.chat.WrongKeyChats import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController @@ -72,6 +73,7 @@ class AuthManagerTest { private val profileCoordinator: ProfileCoordinator = mockk(relaxed = true) private val contactCoordinator: ContactCoordinator = mockk(relaxed = true) private val chatCoordinator: ChatCoordinator = mockk(relaxed = true) + private val wrongKeyChats: WrongKeyChats = mockk(relaxed = true) private val userManagerState = MutableStateFlow(UserManager.State()) private val networkConnectivityListener = mockk(relaxed = true) @@ -122,6 +124,7 @@ class AuthManagerTest { profileCoordinator = profileCoordinator, contactCoordinator = contactCoordinator, chatCoordinator = chatCoordinator, + wrongKeyChats = wrongKeyChats, dispatchers = dispatchers, networkObserver = networkConnectivityListener, ) @@ -258,6 +261,8 @@ class AuthManagerTest { verify { featureFlagController.reset() } verify { appSettings.reset() } verify { userFlags.clearAll() } + // A wrong-key mark is keyed by chat id alone, so the next account must not inherit it. + verify { wrongKeyChats.reset() } } @Test diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt index 21013d8f08..76cb8e4797 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt @@ -18,7 +18,8 @@ import javax.inject.Singleton * (`KeyEnvelope` comment), so nothing here retries. * * [GroupKeyEnvelopes] writes it; a chat whose key later opens drops out. Held in memory only: the - * next launch fetches the bundle again and lands here again if it still fails. + * next launch fetches the bundle again and lands here again if it still fails. Logout calls + * [reset], since chat ids aren't scoped to the account that marked them. */ @Singleton class WrongKeyChats @Inject constructor() { @@ -33,4 +34,8 @@ class WrongKeyChats @Inject constructor() { internal fun clear(chatId: ChatId) { if (chatId in wrong.value) wrong.update { it - chatId } } + + fun reset() { + wrong.value = emptySet() + } } From 3712305e3dc94bd61b218a670758992283c48e07 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 11:04:06 -0400 Subject: [PATCH 6/6] test(persistence): cover the invite grant in the viewer-state write --- .../sources/ChatMetadataDataSourceTest.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt index 6c7df7c2e5..b5b12f2ff8 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt @@ -1,10 +1,18 @@ package com.flipcash.app.persistence.sources import androidx.paging.PagingSource +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.dao.ChatMetadataDao import com.flipcash.app.persistence.entities.ChatMetadataEntity import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ViewerState +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -39,4 +47,31 @@ class ChatMetadataDataSourceTest { fun `a chat id renders as the hex the tables are keyed by`() { assertEquals("ab", dataSource.chatIdHex(ChatId(listOf(0xAB.toByte())))) } + + @Test + fun `the invite grant is stored with the rest of the viewer state`() = runTest { + val dao = mockk(relaxed = true) + mockkObject(FlipcashDatabase.Companion) + try { + every { FlipcashDatabase.getInstance() } returns mockk { every { chatMetadataDao() } returns dao } + + dataSource.updateViewerState( + ChatId(listOf(0xAB.toByte())), + ViewerState(version = 3, permissions = ViewerState.Permissions(canInvite = true)), + ) + + coVerify { + dao.updateViewerStateIfNewer( + chatIdHex = "ab", + muteUntilEpochMs = any(), + muteForever = false, + canEdit = false, + canInvite = true, + version = 3, + ) + } + } finally { + unmockkObject(FlipcashDatabase.Companion) + } + } }