From 092bac6262540bb1112888e3f04735182a4ec6ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 20:38:54 +0000 Subject: [PATCH 01/10] fix(marmot): persist group metadata + decrypted messages across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Marmot/MLS group bugs were causing data loss across app restarts: 1. **Group name disappears, edit screen shows "Group Metadata Not Found".** `CreateGroupScreen` was setting `chatroom.displayName.value` directly in memory and never writing the name into the MLS GroupContext extensions. After restart `MarmotManager.syncMetadataTo` had nothing to read from, and `EditGroupInfoScreen` blew up because `AccountViewModel.updateMarmotGroupMetadata` required existing metadata to copy from. Now `CreateGroupScreen` issues a real GCE commit through `updateMarmotGroupMetadata` so the name is persisted in the MLS extensions on disk. `AccountViewModel.updateMarmotGroupMetadata` tolerates missing prior metadata by constructing a fresh `MarmotGroupData` (creator as admin), so older groups can also be recovered by editing them. `Account.updateMarmotGroupMetadata` now calls `syncMetadataTo` after the local commit so the chatroom UI reflects the new name without waiting for the relay round-trip. 2. **Messages disappear after restart.** `MarmotGroupChatroom.messages` was purely in-memory. Marmot/MLS application messages cannot be re-decrypted once the ratchet has advanced, so relay redelivery is not enough to restore history — the plaintext must be captured at first decryption and persisted. This change introduces `MarmotMessageStore` (quartz interface) and `AndroidMarmotMessageStore` (encrypted, file-based, sharing the same KeyStoreEncryption used for MLS state). `MarmotManager` now exposes `persistDecryptedMessage` / `loadStoredMessages` and clears the log on `leaveGroup`. `GroupEventHandler` persists each new application message after it has been added to the chatroom. On startup, `Account.init` loads any stored messages for restored groups and re-hydrates the chatroom via a new `restoreMessageSync` helper that does not bump the unread counter. --- .../vitorpamplona/amethyst/model/Account.kt | 37 +++- .../model/accountsCache/AccountCacheState.kt | 14 ++ .../model/marmot/AndroidMarmotMessageStore.kt | 199 ++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 25 ++- .../loggedIn/DecryptAndIndexProcessor.kt | 6 + .../chats/marmotGroup/CreateGroupScreen.kt | 12 +- .../amethyst/commons/marmot/MarmotManager.kt | 36 ++++ .../model/marmotGroups/MarmotGroupChatroom.kt | 23 ++ .../model/marmotGroups/MarmotGroupList.kt | 14 ++ .../marmot/mls/group/MarmotMessageStore.kt | 66 ++++++ 10 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6aa8fddc7a..b305077887 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -256,6 +256,7 @@ class Account( val client: INostrClient, val scope: CoroutineScope, val mlsGroupStateStore: MlsGroupStateStore? = null, + val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, ) : IAccount { private var userProfileCache: User? = null @@ -386,7 +387,7 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) - val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it) } + val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore) } val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) @@ -1973,6 +1974,11 @@ class Account( if (!isWriteable()) return val outbound = manager.updateGroupMetadata(nostrGroupId, metadata) + // The MLS commit has already been applied locally — surface the new + // metadata in the chatroom now so the UI reflects it without waiting + // for the relay round-trip. + val chatroom = marmotGroupList.getOrCreateGroup(nostrGroupId) + manager.syncMetadataTo(nostrGroupId, chatroom) client.publish(outbound.signedEvent, groupRelays) } @@ -2438,10 +2444,37 @@ class Account( if (marmotManager != null) { scope.launch(Dispatchers.IO) { marmotManager.restoreAll() - // Sync MIP-01 metadata from restored groups to chatrooms + // Sync MIP-01 metadata from restored groups to chatrooms and + // re-hydrate decrypted messages from persistent storage. + // Note: Marmot MLS application messages cannot be re-decrypted + // after the ratchet advances, so persisted plaintext is the + // only way to restore group history across restarts. marmotManager.activeGroupIds().forEach { groupId -> val chatroom = marmotGroupList.getOrCreateGroup(groupId) marmotManager.syncMetadataTo(groupId, chatroom) + + val storedMessages = marmotManager.loadStoredMessages(groupId) + if (storedMessages.isNotEmpty()) { + Log.d("Account") { + "Restoring ${storedMessages.size} Marmot message(s) for group $groupId" + } + storedMessages.forEach { json -> + try { + val innerEvent = com.vitorpamplona.quartz.nip01Core.core.Event.fromJson(json) + val isNew = cache.justConsume(innerEvent, null, false) + val innerNote = cache.getOrCreateNote(innerEvent.id) + if (isNew) { + innerNote.event = innerEvent + } + marmotGroupList.restoreMessage(groupId, innerNote) + } catch (e: Exception) { + Log.w( + "Account", + "Failed to restore persisted Marmot message for $groupId: ${e.message}", + ) + } + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index c4daa86e07..2e081d05cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -24,6 +24,7 @@ import android.content.ContentResolver import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.service.location.LocationState @@ -117,6 +118,18 @@ class AccountCacheState( "Account ${signer.pubKey.take(8)}… using Marmot store: ${mlsStore::class.simpleName}" } + val marmotMessageStore = + try { + AndroidMarmotMessageStore(rootFilesDir()) + } catch (e: Exception) { + Log.e( + "AccountCacheState", + "Failed to initialize AndroidMarmotMessageStore (Marmot messages will NOT persist across restarts)", + e, + ) + null + } + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -134,6 +147,7 @@ class AccountCacheState( }, ), mlsGroupStateStore = mlsStore, + marmotMessageStore = marmotMessageStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt new file mode 100644 index 0000000000..56a32e2e4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.marmot + +import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of [MarmotMessageStore] using file-based encrypted storage. + * + * Stored alongside the [AndroidMlsGroupStateStore] data: + * ``` + * /mls_groups//messages — encrypted message log + * ``` + * + * The on-disk format (after decryption) is a sequence of length-prefixed + * UTF-8 entries: + * ``` + * uint32 count + * for each entry: + * uint32 length + * byte[length] utf8 + * ``` + * + * The whole blob is rewritten on each append (via atomic rename) — this + * keeps encryption simple (one GCM nonce per write) and is acceptable for + * conversation-scale histories. + */ +class AndroidMarmotMessageStore( + private val rootDir: File, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) : MarmotMessageStore { + private val writeMutex = Mutex() + + init { + Log.d(TAG) { + "Initialized AndroidMarmotMessageStore: rootDir=${rootDir.absolutePath}" + } + } + + private fun groupDir(nostrGroupId: String): File { + require(nostrGroupId.matches(HEX_PATTERN)) { + "Invalid nostrGroupId: must be a hex string" + } + return File(rootDir, "mls_groups/$nostrGroupId") + } + + private fun messagesFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "messages") + + override suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) = withContext(Dispatchers.IO) { + writeMutex.withLock { + try { + val existing = readAll(nostrGroupId).toMutableList() + existing.add(innerEventJson) + writeAll(nostrGroupId, existing) + Log.d(TAG) { + "appendMessage($nostrGroupId): now ${existing.size} message(s) persisted" + } + } catch (e: Exception) { + Log.e(TAG, "appendMessage($nostrGroupId) FAILED: ${e.message}", e) + throw e + } + } + } + + override suspend fun loadMessages(nostrGroupId: String): List = + withContext(Dispatchers.IO) { + try { + val messages = readAll(nostrGroupId) + Log.d(TAG) { + "loadMessages($nostrGroupId): loaded ${messages.size} message(s)" + } + messages + } catch (e: Exception) { + Log.e(TAG, "loadMessages($nostrGroupId) FAILED: ${e.message}", e) + emptyList() + } + } + + override suspend fun delete(nostrGroupId: String) { + withContext(Dispatchers.IO) { + writeMutex.withLock { + val file = messagesFile(nostrGroupId) + if (file.exists()) { + Log.w(TAG) { "delete($nostrGroupId): removing ${file.absolutePath}" } + file.delete() + } + } + } + } + + private fun readAll(nostrGroupId: String): List { + val file = messagesFile(nostrGroupId) + if (!file.exists()) return emptyList() + val encrypted = file.readBytes() + val plain = encryption.decrypt(encrypted) ?: return emptyList() + if (plain.size < 4) return emptyList() + + var offset = 0 + val count = + ((plain[offset++].toInt() and 0xFF) shl 24) or + ((plain[offset++].toInt() and 0xFF) shl 16) or + ((plain[offset++].toInt() and 0xFF) shl 8) or + (plain[offset++].toInt() and 0xFF) + + val result = ArrayList(count.coerceAtMost(MAX_MESSAGES)) + for (i in 0 until count) { + if (offset + 4 > plain.size) break + val len = + ((plain[offset++].toInt() and 0xFF) shl 24) or + ((plain[offset++].toInt() and 0xFF) shl 16) or + ((plain[offset++].toInt() and 0xFF) shl 8) or + (plain[offset++].toInt() and 0xFF) + if (len < 0 || offset + len > plain.size) break + result.add(plain.copyOfRange(offset, offset + len).decodeToString()) + offset += len + } + return result + } + + private fun writeAll( + nostrGroupId: String, + messages: List, + ) { + val file = messagesFile(nostrGroupId) + file.parentFile?.mkdirs() + + val encodedEntries = messages.map { it.encodeToByteArray() } + val totalSize = 4 + encodedEntries.sumOf { 4 + it.size } + val buffer = ByteArray(totalSize) + var offset = 0 + + val count = encodedEntries.size + buffer[offset++] = (count shr 24).toByte() + buffer[offset++] = (count shr 16).toByte() + buffer[offset++] = (count shr 8).toByte() + buffer[offset++] = count.toByte() + + for (entry in encodedEntries) { + val len = entry.size + buffer[offset++] = (len shr 24).toByte() + buffer[offset++] = (len shr 16).toByte() + buffer[offset++] = (len shr 8).toByte() + buffer[offset++] = len.toByte() + entry.copyInto(buffer, offset) + offset += len + } + + val encrypted = encryption.encrypt(buffer) + atomicWrite(file, encrypted) + } + + private fun atomicWrite( + target: File, + data: ByteArray, + ) { + val tempFile = File(target.parentFile, "${target.name}.tmp") + tempFile.writeBytes(data) + if (!tempFile.renameTo(target)) { + tempFile.copyTo(target, overwrite = true) + if (!tempFile.delete()) { + Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } + } + } + } + + companion object { + private const val TAG = "AndroidMarmotMessageStore" + private const val MAX_MESSAGES = 1_000_000 + private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8cb6ebac82..91031ceeed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1509,14 +1509,25 @@ class AccountViewModel( name: String, description: String, ) { - val currentMetadata = - account.marmotManager?.groupMetadata(nostrGroupId) - ?: throw IllegalStateException("Group metadata not found") + val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId) val updatedMetadata = - currentMetadata.copy( - name = name, - description = description, - ) + if (currentMetadata != null) { + currentMetadata.copy( + name = name, + description = description, + ) + } else { + // No MarmotGroupData extension exists yet — this happens for groups + // created before initial metadata was persisted, or right after a + // fresh `createMarmotGroup`. Build a brand-new extension with the + // creator as the sole admin so the GCE proposal carries valid data. + com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData( + nostrGroupId = nostrGroupId, + name = name, + description = description, + adminPubkeys = listOf(account.signer.pubKey), + ) + } val relays = marmotGroupRelays(nostrGroupId) account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index c55d963f86..b019d70055 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -482,6 +482,12 @@ class GroupEventHandler( // Track the message in the Marmot group chatroom account.marmotGroupList.addMessage(result.groupId, innerNote) + + // Persist the decrypted plaintext so the message + // survives an app restart. Marmot/MLS application + // messages cannot be re-decrypted once the ratchet + // has advanced, so we must capture them here. + manager.persistDecryptedMessage(result.groupId, result.innerEventJson) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 627cbc1d47..bb6fb60144 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -69,9 +69,15 @@ fun CreateGroupScreen( val nostrGroupId = RandomInstance.bytes(32).toHexKey() accountViewModel.createMarmotGroup(nostrGroupId) if (groupName.isNotBlank()) { - accountViewModel.account.marmotGroupList - .getOrCreateGroup(nostrGroupId) - .displayName.value = groupName + // Persist the name into the MLS GroupContext extensions + // via a GCE commit — otherwise it lives only in memory + // and is lost on app restart, leaving the edit screen + // unable to find any current metadata. + accountViewModel.updateMarmotGroupMetadata( + nostrGroupId = nostrGroupId, + name = groupName.trim(), + description = "", + ) } nav.nav(Route.MarmotGroupChat(nostrGroupId)) } catch (e: Exception) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 027c285351..209d930fa0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mls.tree.Credential @@ -63,6 +64,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi class MarmotManager( val signer: NostrSigner, store: MlsGroupStateStore, + val messageStore: MarmotMessageStore? = null, ) { val groupManager = MlsGroupManager(store) val keyPackageRotationManager = KeyPackageRotationManager() @@ -219,9 +221,43 @@ class MarmotManager( // Now clean up group state groupManager.removeGroupState(nostrGroupId) subscriptionManager.unsubscribeGroup(nostrGroupId) + try { + messageStore?.delete(nostrGroupId) + } catch (e: Exception) { + Log.w("MarmotManager", "Failed to delete persisted messages for $nostrGroupId: ${e.message}") + } return outboundEvent } + /** + * Persist a freshly decrypted inner event for restart recovery. + * Marmot MLS application messages cannot be re-decrypted after the + * ratchet advances, so the only way to restore them across app restarts + * is to capture the plaintext at decryption time. + */ + suspend fun persistDecryptedMessage( + nostrGroupId: HexKey, + innerEventJson: String, + ) { + try { + messageStore?.appendMessage(nostrGroupId, innerEventJson) + } catch (e: Exception) { + Log.w("MarmotManager", "Failed to persist Marmot message for $nostrGroupId: ${e.message}") + } + } + + /** + * Load all persisted inner event JSONs for a group, in append order. + * Returns an empty list if no message store is configured or none exist. + */ + suspend fun loadStoredMessages(nostrGroupId: HexKey): List = + try { + messageStore?.loadMessages(nostrGroupId) ?: emptyList() + } catch (e: Exception) { + Log.w("MarmotManager", "Failed to load persisted messages for $nostrGroupId: ${e.message}") + emptyList() + } + /** * Remove a member from a group. * Returns the commit GroupEvent to publish. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index d39c3fe8ad..ec69b326c4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -81,6 +81,29 @@ class MarmotGroupChatroom( return false } + /** + * Add a message that is being restored from persistent storage on app + * startup. Behaves like [addMessageSync] but does NOT bump the unread + * count — restored messages were already seen by the user in a previous + * session. + */ + @Synchronized + fun restoreMessageSync(msg: Note): Boolean { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) + + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + changesFlow.get()?.tryEmit(ListChange.Addition(msg)) + return true + } + return false + } + @Synchronized fun removeMessageSync(msg: Note): Boolean { if (msg in messages) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index c6eb34378a..3e6b3b2391 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -49,6 +49,20 @@ class MarmotGroupList { } } + /** + * Add a message that was restored from persistent storage at app startup. + * Does not bump the chatroom's unread counter. + */ + fun restoreMessage( + nostrGroupId: HexKey, + msg: Note, + ) { + val chatroom = getOrCreateGroup(nostrGroupId) + if (chatroom.restoreMessageSync(msg)) { + _groupListChanges.tryEmit(nostrGroupId) + } + } + fun removeMessage( nostrGroupId: HexKey, msg: Note, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt new file mode 100644 index 0000000000..03fc3cede3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls.group + +/** + * Encrypted local storage for decrypted Marmot inner event JSONs. + * + * Marmot MLS group messages are encrypted with per-message ratchet keys that + * advance as each message is decrypted. After the ratchet has advanced past + * a message, that ciphertext can no longer be decrypted — even by its + * original recipient. To make group history survive app restarts we must + * persist the *plaintext* inner events at the moment they are first + * decrypted, rather than relying on relay redelivery. + * + * Implementations MUST encrypt all data at rest — the stored blobs contain + * the contents of private group conversations. + * + * Each group's messages are stored independently, keyed by the hex-encoded + * Nostr group ID (the `h` tag value from MIP-01). + */ +interface MarmotMessageStore { + /** + * Append a decrypted inner event JSON to the group's persisted message log. + * Implementations should be tolerant to duplicate appends. + * + * @param nostrGroupId hex-encoded Nostr group ID + * @param innerEventJson the decrypted inner Nostr event JSON (e.g., kind:9 chat) + */ + suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) + + /** + * Load all persisted inner event JSONs for a group, in append order. + * + * @param nostrGroupId hex-encoded Nostr group ID + * @return list of inner event JSON strings, empty if none + */ + suspend fun loadMessages(nostrGroupId: String): List + + /** + * Delete all persisted messages for a group (after leaving). + * + * @param nostrGroupId hex-encoded Nostr group ID + */ + suspend fun delete(nostrGroupId: String) +} From 21b83b57776fb08d5486643323dd0c09550b24a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 20:54:13 +0000 Subject: [PATCH 02/10] style(marmot): spotless reformat in Account.kt restore loop --- .../src/main/java/com/vitorpamplona/amethyst/model/Account.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b305077887..ea6b17f937 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2460,7 +2460,9 @@ class Account( } storedMessages.forEach { json -> try { - val innerEvent = com.vitorpamplona.quartz.nip01Core.core.Event.fromJson(json) + val innerEvent = + com.vitorpamplona.quartz.nip01Core.core.Event + .fromJson(json) val isNew = cache.justConsume(innerEvent, null, false) val innerNote = cache.getOrCreateNote(innerEvent.id) if (isNew) { From c2348328603f2c7b2a6ccfd1b4e42c3cd4421ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 20:57:06 +0000 Subject: [PATCH 03/10] style(call): spotless reformat in CallManagerTest Pre-existing formatting violation surfaced by `./gradlew spotlessApply`. Unrelated to the Marmot persistence fix in this branch. --- .../com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt index 463dab25aa..6ffda5ae11 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt @@ -1844,8 +1844,7 @@ class CallManagerTest { // observes the echo, detects a self-answer/self-reject in IncomingCall // state and transitions to Ended(ANSWERED_ELSEWHERE / REJECTED). - private fun EphemeralGiftWrapEvent.recipientPubKey(): HexKey? = - tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + private fun EphemeralGiftWrapEvent.recipientPubKey(): HexKey? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) @Test fun acceptCallPublishesAnswerWrappedForSelfSoSiblingDeviceCanStopRinging() = From e3ed63043016582681777ea0233c3e2527e72cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 21:23:50 +0000 Subject: [PATCH 04/10] feat(marmot): notify invitees + Known/New Requests split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user A invites user B to a Marmot/MLS group, B previously had no visible signal that they had been added: no notification fired, the group screen was a single flat list with no notion of "this group was not started by me", and the in-memory chatroom was created but not broadcast to the list UI. Three related changes here. 1. **Invitee Welcome notification.** `EventNotificationConsumer` now handles `WelcomeEvent` (kind:444) inside the unwrapped gift wrap. The push-notification background path does NOT go through `Account.eventProcessor`, so we explicitly call `manager.processWelcome` here on a best-effort basis and then `syncMetadataTo` so the notification body can include the actual group name. A new `marmot:?account=` deep link scheme is parsed by `uriToRoute` so tapping the notification opens the group chat directly. 2. **Known vs New Requests split for Marmot groups.** `MarmotGroupChatroom` gains an `ownerSentMessage` flag mirroring the private-DM `Chatroom` field. `MarmotGroupList` now takes the account's `ownerPubKey` and flips that flag whenever it sees a message authored by the local user, both in the live `addMessage` path and in `restoreMessage` on startup. Two helpers, `markAsKnown` and `notifyGroupChanged`, let the Account layer move groups out of "New Requests" eagerly: - `Account.createMarmotGroup` marks the new group known (the creator obviously knows their own group) - `Account.sendMarmotGroupMessage` marks known immediately on send, before the relay round-trip - `DecryptAndIndexProcessor.processMarmotWelcome` now fires `notifyGroupChanged` after a successful join so an open `MarmotGroupListScreen` re-renders to show the new invite `MarmotGroupListScreen` now has a `PrimaryTabRow` with "Known" and "New Requests" tabs and partitions the loaded groups by the new flag. 3. **Group metadata hydration on the invitee.** Verified that the existing `processMarmotWelcome` already calls `syncMetadataTo` on the chatroom after `MarmotManager.processWelcome` succeeds, so group name / description / admins / members / relays land in the chatroom right after a Welcome is processed. Combined with (2)'s `notifyGroupChanged` emit, the list UI now refreshes immediately. Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`, `:commons:compileAndroidMain`, `:amethyst:compilePlayDebugKotlin` — all BUILD SUCCESSFUL. --- .../vitorpamplona/amethyst/model/Account.kt | 9 +- .../EventNotificationConsumer.kt | 67 +++++++++++++ .../vitorpamplona/amethyst/ui/MainActivity.kt | 16 ++++ .../loggedIn/DecryptAndIndexProcessor.kt | 6 ++ .../marmotGroup/MarmotGroupListScreen.kt | 95 +++++++++++++------ .../model/marmotGroups/MarmotGroupChatroom.kt | 8 ++ .../model/marmotGroups/MarmotGroupList.kt | 39 +++++++- 7 files changed, 208 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ea6b17f937..483352cd5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -381,7 +381,7 @@ class Account( override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) override val marmotGroupList = com.vitorpamplona.amethyst.commons.model.marmotGroups - .MarmotGroupList() + .MarmotGroupList(signer.pubKey) val newNotesPreProcessor = EventProcessor(this, cache) @@ -1741,6 +1741,10 @@ class Account( val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent) cache.justConsumeMyOwnEvent(outbound.signedEvent) + // Sending a message moves the group out of "New Requests" into + // "Known" — do this eagerly before relay round-trip so the UI + // updates immediately. + marmotGroupList.markAsKnown(nostrGroupId) client.publish(outbound.signedEvent, groupRelays) } @@ -1928,6 +1932,9 @@ class Account( val manager = marmotManager ?: return if (!isWriteable()) return manager.createGroup(nostrGroupId) + // Creator owns the group — mark it as "known" immediately so it + // doesn't appear under "New Requests" before the first message. + marmotGroupList.markAsKnown(nostrGroupId) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 8216d9b68a..e2c6bb47bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -46,6 +46,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -168,6 +170,10 @@ class EventNotificationConsumer( is WakeUpEvent -> { wakeUpFor(innerEvent, innerNote, account) } + + is WelcomeEvent -> { + notify(innerEvent, account) + } } } } @@ -433,6 +439,67 @@ class EventNotificationConsumer( } } + private suspend fun notify( + event: WelcomeEvent, + account: Account, + ) { + Log.d(TAG, "New Marmot Welcome to Notify") + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + // a welcome we ourselves emitted + if (event.pubKey == account.signer.pubKey) return + + val nostrGroupId = event.nostrGroupId() ?: return + val manager = account.marmotManager ?: return + + // Best-effort: process the welcome here so the chatroom is hydrated + // before composing the notification body. The push-notification + // background path does NOT go through Account.eventProcessor, so + // without this the invitee would only join the group later, when + // they next open the app and the relay subscription redelivers. + if (!manager.isMember(nostrGroupId)) { + try { + val result = manager.processWelcome(event, nostrGroupId) + if (result is WelcomeResult.Joined) { + val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + manager.syncMetadataTo(result.nostrGroupId, chatroom) + account.marmotGroupList.notifyGroupChanged(result.nostrGroupId) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG) { "Failed to process Marmot Welcome from notification path: ${e.message}" } + } + } + + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: "a private group" + val inviter = LocalCache.getOrCreateUser(event.pubKey) + val inviterName = inviter.toBestDisplayName() + val inviterPicture = inviter.profilePicture() + + val accountNpub = + account.signer.pubKey + .hexToByteArray() + .toNpub() + // marmot:?account= — parsed by uriToRoute below. + val noteUri = "marmot:$nostrGroupId$ACCOUNT_QUERY_PARAM$accountNpub" + + notificationManager() + .sendDMNotification( + id = event.id, + messageBody = "You've been added to $groupName", + senderName = inviterName, + time = event.createdAt, + pictureUrl = inviterPicture, + uri = noteUri, + applicationContext = applicationContext, + accountNpub = accountNpub, + accountPictureUrl = account.userProfile().profilePicture(), + chatroomMembers = null, + ) + } + suspend fun decryptZapContentAuthor( event: LnZapRequestEvent, signer: NostrSigner, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 746c69e69b..6871e1aca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -125,6 +125,10 @@ fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWit fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.startsWith("amethyst+walletconnect:dlnwc?value=") || uri.startsWith("amethyst+walletconnect://dlnwc?value=") +fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:") + +private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$") + fun uriToRoute( uri: String, account: Account, @@ -194,6 +198,18 @@ fun uriToRoute( } } + if (isMarmotGroupRoute(uri)) { + // marmot:?account= + val groupHex = + uri + .removePrefix("marmot:") + .substringBefore("?") + .substringBefore("&") + if (groupHex.matches(MARMOT_HEX)) { + return Route.MarmotGroupChat(groupHex) + } + } + if (isWalletConnectRoute(uri)) { try { val url = UriParser(uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index b019d70055..21273ea2a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -330,6 +330,12 @@ class GiftWrapEventHandler( val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) manager.syncMetadataTo(result.nostrGroupId, chatroom) + // Notify any open MarmotGroupListScreen that a new invited + // group has appeared so it can re-render (the screen + // re-runs `loadGroupList` on every emission). Without this, + // newly-joined groups only show up after a manual refresh. + account.marmotGroupList.notifyGroupChanged(result.nostrGroupId) + // Rotate KeyPackages if needed if (result.needsKeyPackageRotation) { account.publishMarmotKeyPackages() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 586723e493..1f137c9d45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -42,7 +42,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -73,6 +75,7 @@ fun MarmotGroupListScreen( nav: INav, ) { var groupList by remember { mutableStateOf(listOf>()) } + var selectedTab by remember { mutableStateOf(0) } // Load group list LaunchedEffect(Unit) { @@ -97,6 +100,10 @@ fun MarmotGroupListScreen( } } + val knownGroups = remember(groupList) { groupList.filter { it.second.ownerSentMessage } } + val newRequestGroups = remember(groupList) { groupList.filter { !it.second.ownerSentMessage } } + val visibleGroups = if (selectedTab == 0) knownGroups else newRequestGroups + Scaffold( topBar = { TopAppBar( @@ -117,37 +124,65 @@ fun MarmotGroupListScreen( } }, ) { padding -> - if (groupList.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "No groups yet", - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = "Create a group or wait for an invitation.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { + Text( + text = if (knownGroups.isEmpty()) "Known" else "Known (${knownGroups.size})", + ) + }, + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { + Text( + text = if (newRequestGroups.isEmpty()) "New Requests" else "New Requests (${newRequestGroups.size})", + ) + }, + ) } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - ) { - items(groupList, key = { it.first }) { (groupId, chatroom) -> - MarmotGroupListItem( - groupId = groupId, - chatroom = chatroom, - onClick = { - nav.nav(Route.MarmotGroupChat(groupId)) - }, - ) - HorizontalDivider() + + if (visibleGroups.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (selectedTab == 0) "No groups yet" else "No invitations", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = + if (selectedTab == 0) { + "Create a group or accept an invitation." + } else { + "When someone adds you to a group it will show up here until you reply." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(visibleGroups, key = { it.first }) { (groupId, chatroom) -> + MarmotGroupListItem( + groupId = groupId, + chatroom = chatroom, + onClick = { + nav.nav(Route.MarmotGroupChat(groupId)) + }, + ) + HorizontalDivider() + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index ec69b326c4..54b7f102ed 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -49,6 +49,14 @@ class MarmotGroupChatroom( var newestMessage: Note? = null val unreadCount = MutableStateFlow(0) + /** + * True if the local user has ever sent an application message in this + * group, OR explicitly created/owns it. Used by list UIs to split groups + * into "Known" vs "New Requests" — invitees that have not yet replied + * stay in "New Requests" until they participate. + */ + var ownerSentMessage: Boolean = false + private var changesFlow: WeakReference>> = WeakReference(null) fun changesFlow(): MutableSharedFlow> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 3e6b3b2391..6ea5bd58d2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -30,7 +30,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow * Tracks all Marmot MLS group chatrooms for an account. * Follows the same pattern as [com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList]. */ -class MarmotGroupList { +class MarmotGroupList( + val ownerPubKey: HexKey, +) { var rooms = LargeCache() private set @@ -45,6 +47,9 @@ class MarmotGroupList { ) { val chatroom = getOrCreateGroup(nostrGroupId) if (chatroom.addMessageSync(msg)) { + if (msg.author?.pubkeyHex == ownerPubKey) { + chatroom.ownerSentMessage = true + } _groupListChanges.tryEmit(nostrGroupId) } } @@ -59,10 +64,42 @@ class MarmotGroupList { ) { val chatroom = getOrCreateGroup(nostrGroupId) if (chatroom.restoreMessageSync(msg)) { + if (msg.author?.pubkeyHex == ownerPubKey) { + chatroom.ownerSentMessage = true + } _groupListChanges.tryEmit(nostrGroupId) } } + /** + * Mark a group as "known" by the local user — used right after the user + * creates a group, so the creator doesn't appear under "New Requests" + * until they post their first message. + */ + fun markAsKnown(nostrGroupId: HexKey) { + val chatroom = getOrCreateGroup(nostrGroupId) + if (!chatroom.ownerSentMessage) { + chatroom.ownerSentMessage = true + _groupListChanges.tryEmit(nostrGroupId) + } + } + + /** + * Notify subscribers that a group's state has changed (e.g., the invitee + * just joined via a Welcome and metadata was synced into the chatroom). + * Used when no message addition occurs but the list UI should refresh. + */ + fun notifyGroupChanged(nostrGroupId: HexKey) { + _groupListChanges.tryEmit(nostrGroupId) + } + + /** + * Whether the local user has ever sent a message in this group (or + * explicitly created it). Mirrors `ChatroomList.hasSentMessagesTo` for + * private DMs. + */ + fun hasSentMessagesTo(nostrGroupId: HexKey): Boolean = rooms.get(nostrGroupId)?.ownerSentMessage == true + fun removeMessage( nostrGroupId: HexKey, msg: Note, From a7960f9484de677d9d6f40f1971bfab3fc4d3e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 21:54:57 +0000 Subject: [PATCH 05/10] fix(marmot): don't mark own messages unread + pad empty group list - `MarmotGroupList.addMessage` now routes self-authored messages through `restoreMessageSync` (the no-unread-bump path) so the relay round-trip of a message the user just sent doesn't show up as an unread bold entry in the group list and doesn't bump the unread badge counter. - `MarmotGroupListScreen` empty state now has 32dp horizontal padding and centered text alignment so the "No invitations" / "No groups yet" copy doesn't run into the screen edges. --- .../chats/marmotGroup/MarmotGroupListScreen.kt | 7 ++++++- .../commons/model/marmotGroups/MarmotGroupList.kt | 13 +++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 1f137c9d45..1f4b1f8381 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -148,13 +148,17 @@ fun MarmotGroupListScreen( if (visibleGroups.isEmpty()) { Box( - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = if (selectedTab == 0) "No groups yet" else "No invitations", style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, ) Text( text = @@ -165,6 +169,7 @@ fun MarmotGroupListScreen( }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, modifier = Modifier.padding(top = 4.dp), ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 6ea5bd58d2..30d6930853 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -46,8 +46,17 @@ class MarmotGroupList( msg: Note, ) { val chatroom = getOrCreateGroup(nostrGroupId) - if (chatroom.addMessageSync(msg)) { - if (msg.author?.pubkeyHex == ownerPubKey) { + val isSelfAuthored = msg.author?.pubkeyHex == ownerPubKey + // Use the quiet path for our own messages so the relay round-trip + // doesn't mark the user's own outgoing message as unread. + val added = + if (isSelfAuthored) { + chatroom.restoreMessageSync(msg) + } else { + chatroom.addMessageSync(msg) + } + if (added) { + if (isSelfAuthored) { chatroom.ownerSentMessage = true } _groupListChanges.tryEmit(nostrGroupId) From d5ec7fcaf36372c2f0e3f87248c0d56527f302ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 22:18:45 +0000 Subject: [PATCH 06/10] fix(marmot): persist KeyPackage bundles + auto-publish at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user A wants to invite user B to a Marmot group, A must fetch B's published KeyPackage (kind:30443) from relays. If B has never had one published, A's `fetchKeyPackageAndAddMember` returns "No KeyPackage found" and the invite silently fails — which matches the user's report that "creating a new group with another user, nothing happens for that user". Two fixes: ### 1. Persist KeyPackageBundles to disk `KeyPackageRotationManager` was 100% in-memory: every restart wiped both the active bundles (with their private init/encryption/signature keys) AND the pending-rotation set. The relay echo of the kind:30443 event is meaningless without the matching private keys, so once a session ended the user could no longer process Welcome events referencing the published KeyPackage. This change adds a quartz `KeyPackageBundleStore` interface and an Android `AndroidKeyPackageBundleStore` implementation backed by the same `KeyStoreEncryption` (AES/GCM via Android KeyStore) used by `AndroidMlsGroupStateStore`. Storage layout: /marmot_keypackages/state — encrypted snapshot `KeyPackageRotationManager` accepts the store via constructor, encodes the `(activeBundles, pendingRotations)` pair into a versioned TLS snapshot, and persists after every state-mutating call: `generateKeyPackage`, `markConsumed`, `markConsumedByRef`, `clearPendingRotation`, `rotateSlot`. A new `restoreFromStore()` method is invoked from `MarmotManager.restoreAll()` at startup. The store is wired through: MarmotManager(... keyPackageStore) → Account(... marmotKeyPackageStore) → AccountCacheState (constructs AndroidKeyPackageBundleStore) ### 2. Ensure a published KeyPackage at app startup `Account.init` now calls a new private `ensureMarmotKeyPackagePublished` right after `marmotManager.restoreAll()`. If no active bundle exists in memory after the restore (i.e., the user never published one), it generates a fresh bundle (which the rotation manager auto-persists) and publishes the corresponding `KeyPackageEvent` to the user's outbox relays. Best-effort — failures are logged and swallowed so a flaky relay or missing outbox config at startup doesn't break account init. The redundant `LaunchedEffect` in `MarmotGroupListScreen` that did the same thing on first screen visit is now removed: KeyPackage publishing is no longer tied to the user navigating to the group list. This means freshly installed accounts (and any account that had never opened the Marmot Groups screen) will now have a published KeyPackage on relays as soon as the account loads, so other users can actually invite them. Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`, `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL. --- .../vitorpamplona/amethyst/model/Account.kt | 55 +++++++- .../model/accountsCache/AccountCacheState.kt | 14 ++ .../marmot/AndroidKeyPackageBundleStore.kt | 127 ++++++++++++++++++ .../marmotGroup/MarmotGroupListScreen.kt | 13 +- .../amethyst/commons/marmot/MarmotManager.kt | 7 +- .../mip00KeyPackages/KeyPackageBundleStore.kt | 58 ++++++++ .../KeyPackageRotationManager.kt | 112 ++++++++++++++- 7 files changed, 373 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 483352cd5a..0748d78572 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -257,6 +257,7 @@ class Account( val scope: CoroutineScope, val mlsGroupStateStore: MlsGroupStateStore? = null, val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, + val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, ) : IAccount { private var userProfileCache: User? = null @@ -387,7 +388,7 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) - val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore) } + val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) } val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) @@ -1905,6 +1906,43 @@ class Account( client.publish(event, outboxRelays.flow.value) } + /** + * Ensure the local user has at least one active KeyPackage bundle and + * a published KeyPackage event on relays. Called from [init] after + * Marmot state has been restored from disk. + * + * - If [KeyPackageRotationManager] already has an active bundle (from + * the persisted snapshot), we trust the previous session and do + * nothing. The matching kind:30443 should already be on relays from + * when the bundle was first generated. + * - Otherwise we generate a fresh bundle (which is now persisted to + * disk by [KeyPackageRotationManager.generateKeyPackage]) and + * publish the corresponding event. + * + * Best-effort: failures are logged but never propagated. We don't want + * a flaky relay or missing outbox config at startup to crash account + * initialization. + */ + private suspend fun ensureMarmotKeyPackagePublished() { + val manager = marmotManager ?: return + if (!isWriteable()) return + try { + if (manager.hasActiveKeyPackages()) { + Log.d("Account") { + "ensureMarmotKeyPackagePublished: already have an active KeyPackage bundle" + } + return + } + Log.d("Account") { + "ensureMarmotKeyPackagePublished: no active bundle — generating + publishing" + } + publishMarmotKeyPackage() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("Account", "ensureMarmotKeyPackagePublished failed: ${e.message}", e) + } + } + /** * Check if a KeyPackage has been published, either locally generated * in this session or found in the local cache from a previous session. @@ -2451,6 +2489,21 @@ class Account( if (marmotManager != null) { scope.launch(Dispatchers.IO) { marmotManager.restoreAll() + + // Ensure the local user has a KeyPackage published to relays + // so other users can invite them to groups. Without this, + // freshly installed accounts (and accounts that never opened + // the Marmot Group screen) would never have an active + // KeyPackage on the relays, and any inviter trying to add + // them would fail with "No KeyPackage found". + // + // The KeyPackage bundle (private keys included) is persisted + // by KeyPackageRotationManager via marmotKeyPackageStore, so + // restoreAll() above has already restored any previously + // generated bundles. Only generate-and-publish if no active + // bundle exists in memory after restore. + ensureMarmotKeyPackagePublished() + // Sync MIP-01 metadata from restored groups to chatrooms and // re-hydrate decrypted messages from persistent storage. // Note: Marmot MLS application messages cannot be re-decrypted diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 2e081d05cf..baf2ee137d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -24,6 +24,7 @@ import android.content.ContentResolver import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore @@ -130,6 +131,18 @@ class AccountCacheState( null } + val marmotKeyPackageStore = + try { + AndroidKeyPackageBundleStore(rootFilesDir()) + } catch (e: Exception) { + Log.e( + "AccountCacheState", + "Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", + e, + ) + null + } + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -148,6 +161,7 @@ class AccountCacheState( ), mlsGroupStateStore = mlsStore, marmotMessageStore = marmotMessageStore, + marmotKeyPackageStore = marmotKeyPackageStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt new file mode 100644 index 0000000000..1a18bf77bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.marmot + +import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of [KeyPackageBundleStore] using file-based encrypted storage. + * + * Storage layout: + * ``` + * /marmot_keypackages/state — encrypted snapshot + * ``` + * + * The blob contains private key material — init keys, encryption keys, + * signature keys — that the MLS engine needs to process Welcome events + * received days or weeks after the corresponding KeyPackage was published. + * It is encrypted at rest with [KeyStoreEncryption] (AES/GCM via Android + * KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. + */ +class AndroidKeyPackageBundleStore( + private val rootDir: File, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) : KeyPackageBundleStore { + private val mutex = Mutex() + + init { + Log.d(TAG) { + "Initialized AndroidKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" + } + } + + private fun stateFile(): File = File(rootDir, "marmot_keypackages/state") + + override suspend fun save(snapshot: ByteArray) = + withContext(Dispatchers.IO) { + mutex.withLock { + val file = stateFile() + try { + file.parentFile?.mkdirs() + val encrypted = encryption.encrypt(snapshot) + atomicWrite(file, encrypted) + Log.d(TAG) { + "save(): wrote ${encrypted.size} encrypted bytes (${snapshot.size} plain)" + } + } catch (e: Exception) { + Log.e(TAG, "save() FAILED: ${e.message}", e) + throw e + } + } + } + + override suspend fun load(): ByteArray? = + withContext(Dispatchers.IO) { + val file = stateFile() + if (!file.exists()) { + Log.d(TAG) { "load(): no state file at ${file.absolutePath}" } + return@withContext null + } + try { + val encrypted = file.readBytes() + val decrypted = encryption.decrypt(encrypted) + Log.d(TAG) { + "load(): read ${encrypted.size} encrypted bytes → ${decrypted?.size ?: -1} plain" + } + decrypted + } catch (e: Exception) { + Log.e(TAG, "load() FAILED: ${e.message}", e) + null + } + } + + override suspend fun delete() { + withContext(Dispatchers.IO) { + mutex.withLock { + val file = stateFile() + if (file.exists()) { + Log.w(TAG) { "delete(): removing ${file.absolutePath}" } + file.delete() + } + } + } + } + + private fun atomicWrite( + target: File, + data: ByteArray, + ) { + val tempFile = File(target.parentFile, "${target.name}.tmp") + tempFile.writeBytes(data) + if (!tempFile.renameTo(target)) { + tempFile.copyTo(target, overwrite = true) + if (!tempFile.delete()) { + Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } + } + } + } + + companion object { + private const val TAG = "AndroidKeyPackageBundleStore" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 1f4b1f8381..6154ade01c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -89,16 +89,9 @@ fun MarmotGroupListScreen( } } - // Auto-publish KeyPackage if none exists yet - LaunchedEffect(Unit) { - if (!accountViewModel.hasPublishedKeyPackage()) { - try { - accountViewModel.publishMarmotKeyPackage() - } catch (_: Exception) { - // Silently retry on next screen visit - } - } - } + // KeyPackage publishing is handled at Account startup + // (Account.ensureMarmotKeyPackagePublished), so this screen no longer + // needs to do anything to make sure invitees can find a KeyPackage. val knownGroups = remember(groupList) { groupList.filter { it.second.ownerSentMessage } } val newRequestGroups = remember(groupList) { groupList.filter { !it.second.ownerSentMessage } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 209d930fa0..b753fe9dd8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender import com.vitorpamplona.quartz.marmot.OutboundGroupEvent import com.vitorpamplona.quartz.marmot.WelcomeDelivery import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils @@ -65,9 +66,10 @@ class MarmotManager( val signer: NostrSigner, store: MlsGroupStateStore, val messageStore: MarmotMessageStore? = null, + val keyPackageStore: KeyPackageBundleStore? = null, ) { val groupManager = MlsGroupManager(store) - val keyPackageRotationManager = KeyPackageRotationManager() + val keyPackageRotationManager = KeyPackageRotationManager(keyPackageStore) val subscriptionManager = MarmotSubscriptionManager(signer.pubKey) val inboundProcessor = MarmotInboundProcessor(groupManager, keyPackageRotationManager) val outboundProcessor = MarmotOutboundProcessor(groupManager) @@ -83,6 +85,9 @@ class MarmotManager( groupManager.restoreAll() val activeIds = groupManager.activeGroupIds() subscriptionManager.syncWithGroupManager(activeIds) + // Also restore previously-published KeyPackage bundles so that + // Welcomes referencing them remain processable across restarts. + keyPackageRotationManager.restoreFromStore() Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" } } catch (e: Exception) { Log.e("MarmotManager", "Failed to restore Marmot state", e) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt new file mode 100644 index 0000000000..edfcc6fe07 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages + +/** + * Encrypted local storage for the [KeyPackageRotationManager]'s state. + * + * KeyPackageBundles contain private key material (init key, encryption key, + * signature key) that the local user MUST retain on disk so they can process + * Welcome events the inviter sends days or weeks after publishing the + * KeyPackage. Without this storage, every app restart would discard the + * bundles and the user would have to re-publish a fresh KeyPackage — + * meanwhile, any inviter holding the old (now-orphaned) KeyPackage would be + * unable to add them to a group, because the matching private key is gone. + * + * Implementations MUST encrypt all data at rest. The persisted blob is a + * single opaque snapshot of the rotation manager state — both the active + * bundles map and the pending-rotation set are serialized together so they + * stay consistent across crashes. + */ +interface KeyPackageBundleStore { + /** + * Persist a snapshot of the rotation manager state. + * Overwrites any previously saved state. + * + * @param snapshot opaque encoded bytes from + * [KeyPackageRotationManager.snapshotBytes] + */ + suspend fun save(snapshot: ByteArray) + + /** + * Load a previously saved snapshot, or null if none exists. + */ + suspend fun load(): ByteArray? + + /** + * Delete the persisted state (e.g., when wiping the account). + */ + suspend fun delete() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index e030d4583a..ea4ae14176 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.mip00KeyPackages +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 @@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -51,11 +54,110 @@ import kotlinx.coroutines.sync.withLock * Per MIP-00 spec, each user should maintain up to [KeyPackageUtils.MAX_SLOTS] * KeyPackage slots, rotating consumed ones promptly. */ -class KeyPackageRotationManager { +class KeyPackageRotationManager( + private val store: KeyPackageBundleStore? = null, +) { private val mutex = Mutex() private val activeBundles = mutableMapOf() private val pendingRotations = mutableSetOf() + /** + * Restore previously persisted bundles + rotation state from [store]. + * Call once at startup before any other use of this manager. + * Safe to call when no store is configured (no-op in that case). + */ + suspend fun restoreFromStore() { + val store = store ?: return + val bytes = + try { + store.load() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to load persisted KeyPackages: ${e.message}") + null + } ?: return + try { + val decoded = decodeSnapshot(bytes) + mutex.withLock { + activeBundles.clear() + activeBundles.putAll(decoded.first) + pendingRotations.clear() + pendingRotations.addAll(decoded.second) + } + Log.d("KeyPackageRotationManager") { + "Restored ${decoded.first.size} active KeyPackage bundle(s), ${decoded.second.size} pending rotation" + } + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to decode persisted KeyPackages: ${e.message}") + } + } + + /** + * Encode the current rotation manager state to opaque bytes for + * persistence. Caller must hold the mutex. + */ + private fun snapshotBytesUnlocked(): ByteArray { + val writer = TlsWriter() + // version + writer.putUint16(SNAPSHOT_VERSION) + // active bundles + writer.putUint32(activeBundles.size.toLong()) + for ((slot, bundle) in activeBundles) { + writer.putOpaque2(slot.encodeToByteArray()) + writer.putOpaque4(bundle.keyPackage.toTlsBytes()) + writer.putOpaque2(bundle.initPrivateKey) + writer.putOpaque2(bundle.encryptionPrivateKey) + writer.putOpaque2(bundle.signaturePrivateKey) + } + // pending rotations + writer.putUint32(pendingRotations.size.toLong()) + for (slot in pendingRotations) { + writer.putOpaque2(slot.encodeToByteArray()) + } + return writer.toByteArray() + } + + /** + * Decode the persisted snapshot. Returns (activeBundles, pendingRotations). + */ + private fun decodeSnapshot(bytes: ByteArray): Pair, Set> { + val reader = TlsReader(bytes) + val version = reader.readUint16() + require(version == SNAPSHOT_VERSION) { + "Unsupported KeyPackage snapshot version: $version" + } + val numBundles = reader.readUint32().toInt() + val bundles = mutableMapOf() + repeat(numBundles) { + val slot = reader.readOpaque2().decodeToString() + val kpBytes = reader.readOpaque4() + val keyPackage = MlsKeyPackage.decodeTls(TlsReader(kpBytes)) + val initPriv = reader.readOpaque2() + val encPriv = reader.readOpaque2() + val sigPriv = reader.readOpaque2() + bundles[slot] = KeyPackageBundle(keyPackage, initPriv, encPriv, sigPriv) + } + val numPending = reader.readUint32().toInt() + val pending = mutableSetOf() + repeat(numPending) { + pending.add(reader.readOpaque2().decodeToString()) + } + return bundles to pending + } + + /** + * Persist the current state to [store]. Caller must hold the mutex. + * Best-effort: persistence failures are logged but don't propagate, so + * a failing disk write never breaks an in-flight Welcome / addMember. + */ + private suspend fun persistUnlocked() { + val store = store ?: return + try { + store.save(snapshotBytesUnlocked()) + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to persist KeyPackages: ${e.message}") + } + } + /** * Generate a new KeyPackage and its associated private bundle. * @@ -98,6 +200,7 @@ class KeyPackageRotationManager { val bundle = KeyPackageBundle(keyPackage, initKp.privateKey, encKp.privateKey, sigKp.privateKey) mutex.withLock { activeBundles[dTagSlot] = bundle + persistUnlocked() } return bundle } @@ -128,6 +231,7 @@ class KeyPackageRotationManager { mutex.withLock { activeBundles.remove(dTagSlot) pendingRotations.add(dTagSlot) + persistUnlocked() } /** @@ -142,6 +246,7 @@ class KeyPackageRotationManager { if (entry != null) { activeBundles.remove(entry.key) pendingRotations.add(entry.key) + persistUnlocked() } } @@ -157,6 +262,7 @@ class KeyPackageRotationManager { suspend fun clearPendingRotation(dTagSlot: String) = mutex.withLock { pendingRotations.remove(dTagSlot) + persistUnlocked() } /** @@ -184,6 +290,7 @@ class KeyPackageRotationManager { val bundle = generateKeyPackage(identity, dTagSlot) mutex.withLock { pendingRotations.remove(dTagSlot) + persistUnlocked() } return bundle } @@ -233,5 +340,8 @@ class KeyPackageRotationManager { /** Proactive rotation after 7 days even if not consumed */ const val MAX_KEY_PACKAGE_AGE_SECONDS = 7L * 24 * 60 * 60 + + /** On-disk snapshot format version for [KeyPackageBundleStore]. */ + private const val SNAPSHOT_VERSION = 1 } } From 99506e3db0ddda92e609138fc568314d929309b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 22:45:50 +0000 Subject: [PATCH 07/10] fix(marmot): land welcomes + group msgs on the invitee's relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user reported that NIP-17 DMs from Bob to Charlie arrive instantly, but Marmot Welcomes and group messages produce nothing on Charlie's device. Three independent bugs combined to make Marmot invisible on the receiving side. ### 1. kind:445 EOSE filter never invalidated when joining a group `MarmotGroupEventsEoseManager` only re-polled its filter set when `account.homeRelays.flow` emitted. So an invitee that just processed a Welcome got the new group added to `MarmotSubscriptionManager` and to `marmotGroupList`, but the per-`h`-tag kind:445 subscription was never actually sent to the relays — the EOSE manager kept using the filter snapshot from app start. The same was true for restored groups on every restart. Fix: add a second job in `MarmotGroupEventsEoseManager.newSub` that collects `account.marmotGroupList.groupListChanges` and calls `invalidateFilters()`. `Account.init`'s restore loop now also fires `marmotGroupList.notifyGroupChanged(groupId)` for every restored group so the EOSE manager picks them up at startup. ### 2. Group metadata had no relays — both sides used different sets `MarmotGroupData.relays` was always empty. `marmotGroupRelays` and `MarmotGroupEventsEoseManager` both fall back to "the local user's own relays" when the metadata has none — so Bob published kind:445 to Bob's outbox while Charlie subscribed on Charlie's home relays. The two only overlap by accident. Fix: `AccountViewModel.updateMarmotGroupMetadata` now stamps the inviter's outbox relays into the GCE proposal (merged with any existing relays). The Welcome carries the GroupContext extensions, so the invitee learns the same canonical relay set at join time. `CreateGroupScreen` now always issues the metadata commit (even when the user left the name blank) so this stamping happens for every new group. ### 3. Welcome gift wrap delivery used a different relay path than NIP-17 `addMarmotGroupMember` had its own ad-hoc relay fallback chain (`outboxRelays + cache.getOrCreateUser(...).dmInboxRelays()`) and explicitly avoided `computeRelayListToBroadcast`. The comment claimed that path returned an empty set in some cases, but the actual code in `computeRelayListToBroadcast(GiftWrapEvent)` falls back through kind:10050 → NIP-65 read → relay hints — exactly the same chain that NIP-17 DMs use, which empirically reach the recipient. Fix: union `computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)` with the previous outbox + dmInboxRelays set so the welcome takes the same route NIP-17 DMs do, plus belt-and-braces fallbacks. Added a log line so we can tell from logcat exactly how many relays the welcome went out on. Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`, `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL. --- .../vitorpamplona/amethyst/model/Account.kt | 32 +++++++++++-------- .../marmot/MarmotGroupEventsEoseManager.kt | 11 +++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 15 +++++++++ .../chats/marmotGroup/CreateGroupScreen.kt | 24 +++++++------- 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 0748d78572..27c6cae758 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1847,29 +1847,29 @@ class Account( // Then send the Welcome gift wrap to the new member. // - // We deliberately do NOT route this through - // computeRelayListToBroadcast(): its GiftWrap branch returns an - // empty relay set when the recipient has no NIP-17 kind:10050 and - // no cached NIP-65 inbox relays, which silently drops the welcome - // and leaves the invitee with no way to discover the group. - // - // Instead, mirror sendNip04PrivateMessage's delivery strategy: - // publish to our own outbox (so we keep a copy and the invitee - // may find it via a shared relay) unioned with the recipient's - // DM inbox relays — User.dmInboxRelays() already falls back - // through NIP-17 kind:10050 → NIP-65 read relays, which is where - // the invitee's AccountGiftWrapsEoseManager actually listens. + // Use the same delivery path that NIP-17 DMs (kind:1059) take — + // computeRelayListToBroadcast() — which has fallbacks for kind:10050 + // → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the + // invitee, so this path is the one we know works. We also union + // with our own outbox + the recipient's dmInboxRelays() as a + // belt-and-braces measure in case the cache hasn't been hydrated + // yet for this contact. if (welcomeDelivery != null) { + val computed = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) val recipientInbox = cache .getOrCreateUser(memberPubKey) .dmInboxRelays() .orEmpty() - val relayList = outboxRelays.flow.value + recipientInbox + val relayList = computed + outboxRelays.flow.value + recipientInbox if (relayList.isEmpty()) { Log.w("Marmot") { "addMarmotGroupMember: no relays to deliver welcome gift wrap to ${memberPubKey.take(8)}…" } + } else { + Log.d("Marmot") { + "addMarmotGroupMember: publishing welcome gift wrap to ${relayList.size} relay(s) for ${memberPubKey.take(8)}…" + } } client.publish(welcomeDelivery.giftWrapEvent, relayList) } @@ -2512,6 +2512,12 @@ class Account( marmotManager.activeGroupIds().forEach { groupId -> val chatroom = marmotGroupList.getOrCreateGroup(groupId) marmotManager.syncMetadataTo(groupId, chatroom) + // Force the kind:445 EOSE manager to re-poll its filter + // set so the restored group's per-`h`-tag subscription + // is actually sent to relays. Without this, restored + // groups would never receive new messages until the user + // explicitly created/joined another group. + marmotGroupList.notifyGroupChanged(groupId) val storedMessages = marmotManager.loadStoredMessages(groupId) if (storedMessages.isNotEmpty()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 6946385521..a5ce461b7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -105,6 +105,17 @@ class MarmotGroupEventsEoseManager( invalidateFilters() } }, + // Critical: invalidate the kind:445 filter set whenever a + // group is joined (via Welcome processing), created, or + // left. Without this the per-group h-tag subscription is + // never sent to relays after the initial connect, so an + // invitee that just joined a group would never actually + // receive any of its messages until the next app restart. + key.account.scope.launch(Dispatchers.IO) { + key.account.marmotGroupList.groupListChanges.collect { + invalidateFilters() + } + }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 91031ceeed..1613cecb74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1510,11 +1510,25 @@ class AccountViewModel( description: String, ) { val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId) + // Stamp the inviter's outbox relays into the group metadata so that + // every member ends up with a single canonical relay set for kind:445 + // GroupEvents. Without this, both the inviter and the invitee fall + // back to their *own* home/outbox relays — which usually do not + // overlap, so kind:445 messages never reach the other side. The + // welcome carries the metadata, so the invitee learns the relays at + // join time. + val outboxRelayStrings = + account.outboxRelays.flow.value + .map { it.url } + val mergedRelays = + (currentMetadata?.relays.orEmpty() + outboxRelayStrings) + .distinct() val updatedMetadata = if (currentMetadata != null) { currentMetadata.copy( name = name, description = description, + relays = mergedRelays, ) } else { // No MarmotGroupData extension exists yet — this happens for groups @@ -1526,6 +1540,7 @@ class AccountViewModel( name = name, description = description, adminPubkeys = listOf(account.signer.pubKey), + relays = mergedRelays, ) } val relays = marmotGroupRelays(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index bb6fb60144..77e42d7a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -68,17 +68,19 @@ fun CreateGroupScreen( try { val nostrGroupId = RandomInstance.bytes(32).toHexKey() accountViewModel.createMarmotGroup(nostrGroupId) - if (groupName.isNotBlank()) { - // Persist the name into the MLS GroupContext extensions - // via a GCE commit — otherwise it lives only in memory - // and is lost on app restart, leaving the edit screen - // unable to find any current metadata. - accountViewModel.updateMarmotGroupMetadata( - nostrGroupId = nostrGroupId, - name = groupName.trim(), - description = "", - ) - } + // Always commit an initial metadata extension so that + // (a) the name (if any) is persisted in MLS extensions + // and survives app restarts, + // (b) the inviter's outbox relays land in the group + // metadata so every member ends up with the same + // canonical relay set for kind:445 — without this, + // invitees would never receive the group's messages. + // Both are handled inside `updateMarmotGroupMetadata`. + accountViewModel.updateMarmotGroupMetadata( + nostrGroupId = nostrGroupId, + name = groupName.trim(), + description = "", + ) nav.nav(Route.MarmotGroupChat(nostrGroupId)) } catch (e: Exception) { isCreating = false From 1c9585e5e43b951f0462fc96f77351837faa4b33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 23:04:58 +0000 Subject: [PATCH 08/10] fix(marmot): look up KeyPackageBundle by Nostr event id + add MarmotDbg logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While adding diagnostic logs to trace why invitees were receiving nothing on Marmot group adds, I found the actual root cause: a fundamental hash mismatch between the value the Welcome event carries and the value the receiver looks up by. ### The bug A Marmot WelcomeEvent (kind:444) carries a tag `["e", ]` referencing the kind:30443 KeyPackage event that was consumed — see `KeyPackageEventTag` and `MarmotWelcomeSender.wrapWelcome`. That `` is the *Nostr event id* (a hash of the signed event JSON). `MarmotInboundProcessor.processWelcome` then called `keyPackageRotationManager.findBundleByRef(hexToBytes(eventId))`, which compares each stored bundle's `keyPackage.reference()` — **the MLS-spec KeyPackageRef, an entirely different hash computed by `MlsCryptoProvider.refHash("MLS 1.0 KeyPackage Reference", encoded)` over the TLS-encoded KeyPackage**. Those two values are never equal, so the lookup ALWAYS missed and every invitee path returned `WelcomeResult.Error("No matching KeyPackageBundle found")`. Combined with the previous in-memory-only storage, this is why nothing ever appeared on the invitee's screen. ### The fix `KeyPackageRotationManager` now also indexes bundles by the Nostr event id of the corresponding kind:30443 event: - `eventIdToSlot: Map` — populated by `recordPublishedEventId(slot, eventId)`, called from `MarmotManager.generateKeyPackageEvent` immediately after signing the event template (which is when the event id is first known). - `findBundleByEventId(eventId)` — looks up the slot via the new index, then returns the bundle. - `markConsumedByEventId(eventId)` — symmetric consume-by-event-id for the welcome receive path. - The persisted snapshot format is bumped from v1 → v2 to include the eventId map. v1 snapshots are still readable (loaded as if the eventId map were empty); republishing a KeyPackage will refill it. Also cleans the index when slots are consumed. `MarmotInboundProcessor.processWelcome` now uses `findBundleByEventId(keyPackageEventId)` instead of `findBundleByRef(hexToBytes(...))`, and `markConsumedByEventId` for the consume call. The dead `hexToBytes` helper + import are removed. ### Diagnostic logging Added `MarmotDbg`-tagged logs across the entire add-member / send / receive chain so the user can `adb logcat -s MarmotDbg` to see exactly what's being sent and what's being received: - `Account.fetchKeyPackageAndAddMember` — querying relays, KP found / not found, kind, author - `Account.addMarmotGroupMember` — commit publish target, welcome delivery presence, full relay union with sources - `Account.sendMarmotGroupMessage` — group, inner kind, target relays - `Account.publishMarmotKeyPackage` + `ensureMarmotKeyPackagePublished` — publish target relays, signed event id - `DecryptAndIndexProcessor.processNewGiftWrap` — gift wrap unwrap result, inner kind, route to Marmot welcome handler - `DecryptAndIndexProcessor.processMarmotWelcome` — manager null, WelcomeResult, synced metadata, group id - `DecryptAndIndexProcessor.GroupEventHandler.add` — kind:445 arrival, membership check, processGroupEvent result, decrypt path - `MarmotInboundProcessor.processWelcome` — eventId lookup, bundle-not-found details, MLS join success - `MarmotGroupEventsEoseManager.updateFilter` — active groups, per-group relay set, fallback usage, total emitted filters - `AccountGiftWrapsEoseManager.updateFilter` — pubkey + dmRelay set used for kind:1059 subscription Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`, `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL. --- .../vitorpamplona/amethyst/model/Account.kt | 84 ++++++++++-- .../marmot/MarmotGroupEventsEoseManager.kt | 16 ++- .../AccountGiftWrapsEoseManager.kt | 9 +- .../loggedIn/DecryptAndIndexProcessor.kt | 93 +++++++++++-- .../amethyst/commons/marmot/MarmotManager.kt | 11 +- .../quartz/marmot/MarmotInboundProcessor.kt | 45 +++++-- .../KeyPackageRotationManager.kt | 124 ++++++++++++++++-- 7 files changed, 329 insertions(+), 53 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 27c6cae758..5d01bf698c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1737,15 +1737,27 @@ class Account( innerEvent: Event, groupRelays: Set, ) { + Log.d("MarmotDbg") { + "sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}… " + + "→ ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" + } val manager = marmotManager ?: return if (!isWriteable()) return val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent) + Log.d("MarmotDbg") { + "sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…" + } cache.justConsumeMyOwnEvent(outbound.signedEvent) // Sending a message moves the group out of "New Requests" into // "Known" — do this eagerly before relay round-trip so the UI // updates immediately. marmotGroupList.markAsKnown(nostrGroupId) + if (groupRelays.isEmpty()) { + Log.w("MarmotDbg") { + "sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped" + } + } client.publish(outbound.signedEvent, groupRelays) } @@ -1758,6 +1770,9 @@ class Account( nostrGroupId: HexKey, memberPubKey: HexKey, ): String { + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}…" + } val manager = marmotManager ?: return "Error: Marmot not initialized" if (!isWriteable()) return "Error: Account is read-only" @@ -1778,6 +1793,11 @@ class Account( .orEmpty() val fetchRelays = memberOutbox + myOutbox + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + + "(memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" + } + // Query across the combined relay set val filterMap = fetchRelays.associateWith { listOf(filter) } @@ -1787,15 +1807,24 @@ class Account( ) if (event == null) { + Log.w("MarmotDbg") { + "fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)" + } return "Error: No KeyPackage found for this user. They may not have published one yet." } + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…" + } + if (event !is com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent) { + Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: unexpected kind ${event.kind}" } return "Error: Unexpected event type received" } val keyPackageBase64 = event.keyPackageBase64() if (keyPackageBase64.isBlank()) { + Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" } return "Error: KeyPackage event has empty content" } @@ -1808,6 +1837,10 @@ class Account( // outbox — that's where we will publish them. val groupRelays = myOutbox.toList() + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}" + } + addMarmotGroupMember( nostrGroupId = nostrGroupId, memberPubKey = memberPubKey, @@ -1830,6 +1863,10 @@ class Account( keyPackageEventId: HexKey, groupRelays: List, ) { + Log.d("MarmotDbg") { + "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " + + "keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}" + } val manager = marmotManager ?: return if (!isWriteable()) return @@ -1842,7 +1879,15 @@ class Account( relays = groupRelays, ) + Log.d("MarmotDbg") { + "addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}… " + + "welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}" + } + // Publish commit first (critical ordering) + Log.d("MarmotDbg") { + "addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" + } client.publish(commitEvent.signedEvent, groupRelays.toSet()) // Then send the Welcome gift wrap to the new member. @@ -1862,16 +1907,26 @@ class Account( .dmInboxRelays() .orEmpty() val relayList = computed + outboxRelays.flow.value + recipientInbox + Log.d("MarmotDbg") { + "addMarmotGroupMember: welcome gift wrap relay sources " + + "computeRelayListToBroadcast=${computed.size} myOutbox=${outboxRelays.flow.value.size} " + + "recipientInbox=${recipientInbox.size} → union=${relayList.size}" + } if (relayList.isEmpty()) { - Log.w("Marmot") { - "addMarmotGroupMember: no relays to deliver welcome gift wrap to ${memberPubKey.take(8)}…" + Log.w("MarmotDbg") { + "addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped" } } else { - Log.d("Marmot") { - "addMarmotGroupMember: publishing welcome gift wrap to ${relayList.size} relay(s) for ${memberPubKey.take(8)}…" + Log.d("MarmotDbg") { + "addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}… " + + "kind:${welcomeDelivery.giftWrapEvent.kind} → ${relayList.size} relay(s): ${relayList.map { it.url }}" } } client.publish(welcomeDelivery.giftWrapEvent, relayList) + } else { + Log.w("MarmotDbg") { + "addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!" + } } } @@ -1901,7 +1956,13 @@ class Account( if (!isWriteable()) return val relays = outboxRelays.flow.value.toList() + Log.d("MarmotDbg") { + "publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}" + } val event = manager.generateKeyPackageEvent(relays) + Log.d("MarmotDbg") { + "publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}…" + } cache.justConsumeMyOwnEvent(event) client.publish(event, outboxRelays.flow.value) } @@ -1927,19 +1988,20 @@ class Account( val manager = marmotManager ?: return if (!isWriteable()) return try { - if (manager.hasActiveKeyPackages()) { - Log.d("Account") { - "ensureMarmotKeyPackagePublished: already have an active KeyPackage bundle" - } + val hasBundle = manager.hasActiveKeyPackages() + Log.d("MarmotDbg") { + "ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${signer.pubKey.take(8)}…" + } + if (hasBundle) { return } - Log.d("Account") { - "ensureMarmotKeyPackagePublished: no active bundle — generating + publishing" + Log.d("MarmotDbg") { + "ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now" } publishMarmotKeyPackage() } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("Account", "ensureMarmotKeyPackagePublished failed: ${e.message}", e) + Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index a5ce461b7c..5457125d48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -56,6 +57,9 @@ class MarmotGroupEventsEoseManager( // Per-group kind:445 filters — route each to the group's own relays val groupStates = manager.subscriptionManager.activeGroupIdsSnapshot() + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager.updateFilter: ${groupStates.size} active group(s), fallbackRelays=${fallbackRelays.size}" + } for (groupId in groupStates) { val filter = manager.subscriptionManager.let { sub -> @@ -77,6 +81,10 @@ class MarmotGroupEventsEoseManager( .normalizeOrNull(it) }?.toSet() val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager.updateFilter: group=${groupId.take(8)}… → ${relaysForGroup.size} relay(s) " + + "(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}" + } for (relay in relaysForGroup) { result.add(RelayBasedFilter(relay = relay, filter = filter)) } @@ -90,6 +98,9 @@ class MarmotGroupEventsEoseManager( } } + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager.updateFilter: emitting ${result.size} RelayBasedFilter(s)" + } return result } @@ -112,7 +123,10 @@ class MarmotGroupEventsEoseManager( // invitee that just joined a group would never actually // receive any of its messages until the next app restart. key.account.scope.launch(Dispatchers.IO) { - key.account.marmotGroupList.groupListChanges.collect { + key.account.marmotGroupList.groupListChanges.collect { changedGroupId -> + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager: groupListChanges → ${changedGroupId.take(8)}… invalidating filters" + } invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index ab4de28752..54b9e0fd4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -45,7 +46,12 @@ class AccountGiftWrapsEoseManager( ): List { // Only loads DMs if the account is writeable return if (key.account.isWriteable()) { - key.account.dmRelays.flow.value.flatMap { relay -> + val relays = key.account.dmRelays.flow.value + Log.d("MarmotDbg") { + "AccountGiftWrapsEoseManager.updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… " + + "subscribing kind:1059 on ${relays.size} dmRelay(s): ${relays.map { it.url }}" + } + relays.flatMap { relay -> filterGiftWrapsToPubkey( relay = relay, pubkey = user(key).pubkeyHex, @@ -53,6 +59,7 @@ class AccountGiftWrapsEoseManager( ) } } else { + Log.d("MarmotDbg") { "AccountGiftWrapsEoseManager.updateFilter: account not writeable, skipping" } emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 21273ea2a9..940cb2a6b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -293,12 +293,27 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { - val innerGift = event.unwrapOrNull(account.signer) ?: return + Log.d("MarmotDbg") { + "GiftWrapEventHandler.processNewGiftWrap: id=${event.id.take(8)}… recipient=${event.recipientPubKey()?.take(8)}…" + } + val innerGift = event.unwrapOrNull(account.signer) + if (innerGift == null) { + Log.w("MarmotDbg") { + "GiftWrapEventHandler.processNewGiftWrap: unwrap returned null (decrypt failed) for id=${event.id.take(8)}…" + } + return + } + Log.d("MarmotDbg") { + "GiftWrapEventHandler.processNewGiftWrap: unwrapped innerKind=${innerGift.kind} innerId=${innerGift.id.take(8)}…" + } eventNote.event = event.copyNoContent() // Check if the unwrapped event is a Marmot WelcomeEvent (kind:444) if (MarmotInboundProcessor.isWelcomeEvent(innerGift)) { + Log.d("MarmotDbg") { + "GiftWrapEventHandler: detected Marmot WelcomeEvent — routing to processMarmotWelcome" + } processMarmotWelcome(innerGift, eventNote, publicNote) return } @@ -315,20 +330,41 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { - val manager = account.marmotManager ?: return - if (innerEvent !is WelcomeEvent) return + Log.d("MarmotDbg") { + "processMarmotWelcome: innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}…" + } + val manager = account.marmotManager + if (manager == null) { + Log.w("MarmotDbg") { "processMarmotWelcome: marmotManager is null — Marmot store probably failed to init" } + return + } + if (innerEvent !is WelcomeEvent) { + Log.w("MarmotDbg") { "processMarmotWelcome: inner is not WelcomeEvent (kind=${innerEvent.kind})" } + return + } - val nostrGroupId = innerEvent.nostrGroupId() ?: return + val nostrGroupId = innerEvent.nostrGroupId() + if (nostrGroupId == null) { + Log.w("MarmotDbg") { "processMarmotWelcome: WelcomeEvent missing 'h' tag (nostrGroupId)" } + return + } + Log.d("MarmotDbg") { "processMarmotWelcome: invoking manager.processWelcome group=${nostrGroupId.take(8)}…" } val result = manager.processWelcome(innerEvent, nostrGroupId) when (result) { is WelcomeResult.Joined -> { - Log.d("GiftWrapEventHandler", "Joined Marmot group ${result.nostrGroupId}") + Log.d("MarmotDbg") { + "processMarmotWelcome: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}" + } // Sync MIP-01 metadata from group extensions to chatroom val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) manager.syncMetadataTo(result.nostrGroupId, chatroom) + Log.d("MarmotDbg") { + "processMarmotWelcome: synced metadata name=${chatroom.displayName.value} " + + "members=${chatroom.memberCount.value} relays=${chatroom.relays.value}" + } // Notify any open MarmotGroupListScreen that a new invited // group has appeared so it can re-render (the screen @@ -343,7 +379,7 @@ class GiftWrapEventHandler( } is WelcomeResult.Error -> { - Log.w("GiftWrapEventHandler") { "Failed to process Marmot Welcome: ${result.message}" } + Log.w("MarmotDbg") { "processMarmotWelcome: ERROR ${result.message}" } } } } @@ -470,18 +506,41 @@ class GroupEventHandler( eventNote: Note, publicNote: Note, ) { - val manager = account.marmotManager ?: return + Log.d("MarmotDbg") { + "GroupEventHandler.add: kind:445 id=${event.id.take(8)}… groupId=${event.groupId()?.take(8)}…" + } + val manager = account.marmotManager + if (manager == null) { + Log.w("MarmotDbg") { "GroupEventHandler.add: marmotManager is null" } + return + } - val groupId = event.groupId() ?: return - if (!manager.isMember(groupId)) return + val groupId = event.groupId() + if (groupId == null) { + Log.w("MarmotDbg") { "GroupEventHandler.add: kind:445 missing 'h' tag" } + return + } + if (!manager.isMember(groupId)) { + Log.w("MarmotDbg") { + "GroupEventHandler.add: not a member of group=${groupId.take(8)}… — dropping kind:445 ${event.id.take(8)}…" + } + return + } try { val result = manager.processGroupEvent(event) + Log.d("MarmotDbg") { + "GroupEventHandler.add: processGroupEvent returned ${result::class.simpleName} for group=${groupId.take(8)}…" + } when (result) { is GroupEventResult.ApplicationMessage -> { // Parse the inner event JSON and index it val innerEvent = Event.fromJson(result.innerEventJson) + Log.d("MarmotDbg") { + "GroupEventHandler.add: ApplicationMessage decrypted innerKind=${innerEvent.kind} " + + "innerId=${innerEvent.id.take(8)}… author=${innerEvent.pubKey.take(8)}…" + } if (cache.justConsume(innerEvent, null, false)) { val innerNote = cache.getOrCreateNote(innerEvent.id) innerNote.event = innerEvent @@ -494,31 +553,37 @@ class GroupEventHandler( // messages cannot be re-decrypted once the ratchet // has advanced, so we must capture them here. manager.persistDecryptedMessage(result.groupId, result.innerEventJson) + } else { + Log.d("MarmotDbg") { "GroupEventHandler.add: inner event already in cache (duplicate)" } } } is GroupEventResult.CommitProcessed -> { - Log.d("GroupEventHandler", "Commit processed for group ${result.groupId}, epoch=${result.newEpoch}") + Log.d("MarmotDbg") { + "GroupEventHandler.add: CommitProcessed group=${result.groupId.take(8)}… newEpoch=${result.newEpoch}" + } // Sync MIP-01 metadata after epoch advance (extensions may have changed) val chatroom = account.marmotGroupList.getOrCreateGroup(result.groupId) manager.syncMetadataTo(result.groupId, chatroom) } is GroupEventResult.CommitPending -> { - Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}") + Log.d("MarmotDbg") { + "GroupEventHandler.add: CommitPending group=${result.groupId.take(8)}… epoch=${result.epoch}" + } } is GroupEventResult.Duplicate -> { - Log.d("GroupEventHandler") { "Duplicate GroupEvent for group ${result.groupId}" } + Log.d("MarmotDbg") { "GroupEventHandler.add: Duplicate kind:445 for group=${result.groupId.take(8)}…" } } is GroupEventResult.Error -> { - Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" } + Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" } } } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("GroupEventHandler", "Failed to process GroupEvent", e) + Log.e("MarmotDbg", "GroupEventHandler.add: exception processing kind:445", e) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b753fe9dd8..ef7fea4e1c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -313,7 +313,12 @@ class MarmotManager( relays = relays, ) - return signer.sign(template) + val signed = signer.sign(template) + // Welcome receivers identify the consumed KeyPackage by its Nostr + // event id (the MIP-02 "e" tag), not by the MLS reference hash, so + // remember the mapping right after we know the signed event id. + keyPackageRotationManager.recordPublishedEventId(dTagSlot, signed.id) + return signed } /** @@ -339,7 +344,9 @@ class MarmotManager( keyPackageRef = keyPackageRef, relays = relays, ) - signer.sign(template) + val signed = signer.sign(template) + keyPackageRotationManager.recordPublishedEventId(slot, signed.id) + signed } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index df7bbab55a..d4b46997b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.io.encoding.Base64 @@ -229,6 +228,10 @@ class MarmotInboundProcessor( nostrGroupId: HexKey, ): WelcomeResult = try { + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { + "MarmotInboundProcessor.processWelcome: group=${nostrGroupId.take(8)}… eventId=${welcomeEvent.id.take(8)}…" + } // Validate the caller-provided nostrGroupId matches the Welcome event's own h tag val eventGroupId = welcomeEvent.nostrGroupId() if (eventGroupId != null && eventGroupId != nostrGroupId) { @@ -242,26 +245,49 @@ class MarmotInboundProcessor( if (keyPackageEventId == null) { return WelcomeResult.Error("WelcomeEvent missing KeyPackage event ID tag") } + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { + "MarmotInboundProcessor.processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…" + } - // Find the KeyPackageBundle that was consumed - val bundle = - keyPackageRotationManager.findBundleByRef( - hexToBytes(keyPackageEventId), - ) ?: return WelcomeResult.Error( + // Find the KeyPackageBundle that was consumed. + // + // The Welcome's "e" tag carries the *Nostr event id* of the + // kind:30443 event (NOT the MLS reference hash), so we must + // resolve it via the eventId→slot index that + // [MarmotManager.generateKeyPackageEvent] populates after + // signing each KeyPackageEvent. + val bundle = keyPackageRotationManager.findBundleByEventId(keyPackageEventId) + if (bundle == null) { + com.vitorpamplona.quartz.utils.Log + .w("MarmotDbg") { + "MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle for eventId=${keyPackageEventId.take(8)}… " + + "— inviter referenced a KeyPackage we don't have private keys for. " + + "Either the bundle was generated in a previous session and never persisted, " + + "or this account never published this KeyPackage." + } + return WelcomeResult.Error( "No matching KeyPackageBundle found for event $keyPackageEventId", ) + } + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: bundle found — invoking groupManager.processWelcome" } // Join the group groupManager.processWelcome(nostrGroupId, welcomeBytes, bundle) + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: groupManager.processWelcome succeeded for ${nostrGroupId.take(8)}…" } // Mark the KeyPackage as consumed — triggers rotation - keyPackageRotationManager.markConsumedByRef(hexToBytes(keyPackageEventId)) + keyPackageRotationManager.markConsumedByEventId(keyPackageEventId) WelcomeResult.Joined( nostrGroupId = nostrGroupId, needsKeyPackageRotation = keyPackageRotationManager.needsRotation(), ) } catch (e: Exception) { + com.vitorpamplona.quartz.utils.Log + .w("MarmotDbg", "MarmotInboundProcessor.processWelcome: exception ${e.message}", e) WelcomeResult.Error("Failed to process Welcome: ${e.message}", e) } @@ -455,9 +481,4 @@ class MarmotInboundProcessor( "Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)", ) } - - private fun hexToBytes(hex: HexKey?): ByteArray { - if (hex == null) return ByteArray(0) - return hex.hexToByteArray() - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index ea4ae14176..e40af16042 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.sync.Mutex @@ -61,6 +62,19 @@ class KeyPackageRotationManager( private val activeBundles = mutableMapOf() private val pendingRotations = mutableSetOf() + /** + * Maps the Nostr event id (kind:30443) of a published KeyPackage to the + * d-tag slot whose bundle backs it. The welcome event references its + * consumed KeyPackage by Nostr event id (the MIP-02 "e" tag), but + * [activeBundles] is keyed by d-tag slot — so we need this side index + * to recover the matching bundle on the receive side. + * + * Populated by [recordPublishedEventId], which is called right after + * the kind:30443 event is signed (the event id is only known then). + * Also persisted in the snapshot so it survives app restart. + */ + private val eventIdToSlot = mutableMapOf() + /** * Restore previously persisted bundles + rotation state from [store]. * Call once at startup before any other use of this manager. @@ -79,18 +93,27 @@ class KeyPackageRotationManager( val decoded = decodeSnapshot(bytes) mutex.withLock { activeBundles.clear() - activeBundles.putAll(decoded.first) + activeBundles.putAll(decoded.bundles) pendingRotations.clear() - pendingRotations.addAll(decoded.second) + pendingRotations.addAll(decoded.pending) + eventIdToSlot.clear() + eventIdToSlot.putAll(decoded.eventIdToSlot) } Log.d("KeyPackageRotationManager") { - "Restored ${decoded.first.size} active KeyPackage bundle(s), ${decoded.second.size} pending rotation" + "Restored ${decoded.bundles.size} active KeyPackage bundle(s), " + + "${decoded.pending.size} pending rotation, ${decoded.eventIdToSlot.size} eventId mapping(s)" } } catch (e: Exception) { Log.w("KeyPackageRotationManager", "Failed to decode persisted KeyPackages: ${e.message}") } } + private data class Snapshot( + val bundles: Map, + val pending: Set, + val eventIdToSlot: Map, + ) + /** * Encode the current rotation manager state to opaque bytes for * persistence. Caller must hold the mutex. @@ -113,16 +136,22 @@ class KeyPackageRotationManager( for (slot in pendingRotations) { writer.putOpaque2(slot.encodeToByteArray()) } + // eventId → slot (added in v2) + writer.putUint32(eventIdToSlot.size.toLong()) + for ((eventId, slot) in eventIdToSlot) { + writer.putOpaque2(eventId.encodeToByteArray()) + writer.putOpaque2(slot.encodeToByteArray()) + } return writer.toByteArray() } /** - * Decode the persisted snapshot. Returns (activeBundles, pendingRotations). + * Decode the persisted snapshot. */ - private fun decodeSnapshot(bytes: ByteArray): Pair, Set> { + private fun decodeSnapshot(bytes: ByteArray): Snapshot { val reader = TlsReader(bytes) val version = reader.readUint16() - require(version == SNAPSHOT_VERSION) { + require(version == 1 || version == SNAPSHOT_VERSION) { "Unsupported KeyPackage snapshot version: $version" } val numBundles = reader.readUint32().toInt() @@ -141,7 +170,19 @@ class KeyPackageRotationManager( repeat(numPending) { pending.add(reader.readOpaque2().decodeToString()) } - return bundles to pending + // eventId → slot map: only present in v2+. Older snapshots may + // not have this section, in which case the map starts empty and + // will be repopulated as KeyPackages are republished. + val eventIdMap = mutableMapOf() + if (version >= SNAPSHOT_VERSION && reader.hasRemaining) { + val numEventIds = reader.readUint32().toInt() + repeat(numEventIds) { + val eventId = reader.readOpaque2().decodeToString() + val slot = reader.readOpaque2().decodeToString() + eventIdMap[eventId] = slot + } + } + return Snapshot(bundles, pending, eventIdMap) } /** @@ -213,7 +254,13 @@ class KeyPackageRotationManager( /** * Find the bundle whose KeyPackage reference matches the given ref. - * Used when we receive a Welcome and need to find the matching bundle. + * Used when we receive a Welcome and need to find the matching bundle + * via the MLS-spec KeyPackageRef hash. + * + * NOTE: a Marmot Welcome's "e" tag actually carries the *Nostr event id* + * of the kind:30443 event, NOT the MLS reference hash, so the welcome + * receive path must use [findBundleByEventId] instead. This function + * remains for any callers that genuinely need the MLS-ref lookup. */ suspend fun findBundleByRef(keyPackageRef: ByteArray): KeyPackageBundle? = mutex.withLock { @@ -222,6 +269,35 @@ class KeyPackageRotationManager( } } + /** + * Find the bundle for a KeyPackage that was published as the given + * Nostr event id (kind:30443). This is the lookup used by Welcome + * processing — the "e" tag in a [WelcomeEvent] is the Nostr event id. + * + * Requires [recordPublishedEventId] to have been called for the slot + * after the kind:30443 event was signed. + */ + suspend fun findBundleByEventId(eventId: HexKey): KeyPackageBundle? = + mutex.withLock { + val slot = eventIdToSlot[eventId] ?: return@withLock null + activeBundles[slot] + } + + /** + * Record that the bundle for [dTagSlot] has been published as the + * kind:30443 event with [eventId]. Call this immediately after signing + * the event template (the Nostr event id is only known once the event + * has been signed). The mapping is persisted alongside the bundles so + * it survives app restart. + */ + suspend fun recordPublishedEventId( + dTagSlot: String, + eventId: HexKey, + ) = mutex.withLock { + eventIdToSlot[eventId] = dTagSlot + persistUnlocked() + } + /** * Mark a KeyPackage slot as consumed (used in a Welcome). * The slot will be included in [pendingRotationSlots] and should be @@ -230,6 +306,9 @@ class KeyPackageRotationManager( suspend fun markConsumed(dTagSlot: String) = mutex.withLock { activeBundles.remove(dTagSlot) + // Drop any eventId mappings that pointed at this slot. + val staleEventIds = eventIdToSlot.entries.filter { it.value == dTagSlot }.map { it.key } + staleEventIds.forEach { eventIdToSlot.remove(it) } pendingRotations.add(dTagSlot) persistUnlocked() } @@ -244,12 +323,29 @@ class KeyPackageRotationManager( bundle.keyPackage.reference().contentEquals(keyPackageRef) } if (entry != null) { - activeBundles.remove(entry.key) - pendingRotations.add(entry.key) + val consumedSlot = entry.key + activeBundles.remove(consumedSlot) + val staleEventIds = eventIdToSlot.entries.filter { it.value == consumedSlot }.map { it.key } + staleEventIds.forEach { eventIdToSlot.remove(it) } + pendingRotations.add(consumedSlot) persistUnlocked() } } + /** + * Mark a slot as consumed by Nostr event id (the value carried by the + * Welcome's "e" tag). Companion to [findBundleByEventId]. + */ + suspend fun markConsumedByEventId(eventId: HexKey) = + mutex.withLock { + val slot = eventIdToSlot[eventId] ?: return@withLock + activeBundles.remove(slot) + val staleEventIds = eventIdToSlot.entries.filter { it.value == slot }.map { it.key } + staleEventIds.forEach { eventIdToSlot.remove(it) } + pendingRotations.add(slot) + persistUnlocked() + } + /** * Get the d-tag slots that need rotation (KeyPackage was consumed). */ @@ -341,7 +437,11 @@ class KeyPackageRotationManager( /** Proactive rotation after 7 days even if not consumed */ const val MAX_KEY_PACKAGE_AGE_SECONDS = 7L * 24 * 60 * 60 - /** On-disk snapshot format version for [KeyPackageBundleStore]. */ - private const val SNAPSHOT_VERSION = 1 + /** + * On-disk snapshot format version for [KeyPackageBundleStore]. + * v1: bundles + pendingRotations + * v2: + eventIdToSlot map (so welcome lookup by Nostr event id works) + */ + private const val SNAPSHOT_VERSION = 2 } } From 6c723147b0599aac6e8b49647841e142441b4803 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 23:13:49 +0000 Subject: [PATCH 09/10] fix(marmot): route seal-wrapped welcomes to the welcome handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported the only logs Charlie's device produced when Bob added him to a group were: GiftWrapEventHandler.processNewGiftWrap: id=40e2073c… recipient=9a960f0b… GiftWrapEventHandler.processNewGiftWrap: unwrapped innerKind=13 innerId=ffda2949… That kind=13 is the smoking gun. The previous code in `processNewGiftWrap` checked `isWelcomeEvent(innerGift)` directly on the gift-wrap-unwrapped result, expecting kind:444. But Marmot `WelcomeGiftWrap.wrapForRecipient` actually produces a three-layer envelope: GiftWrap (1059) → SealedRumor (13) → WelcomeEvent (444) So `event.unwrapOrNull(signer)` returns the kind:13 Seal, not the WelcomeEvent. The previous check fell through to `eventProcessor. consumeEvent`, which dispatched to `SealedRumorEventHandler`, which unsealed correctly to a kind:444 — and then handed it back to `eventProcessor.consumeEvent` again. There's no LocalCache event handler registered for kind:444, so the WelcomeEvent was silently dropped on every invitee. Fix: extract the welcome-handling code from `GiftWrapEventHandler. processMarmotWelcome` into a top-level `processMarmotWelcomeFlow` helper, and call it from BOTH: - `GiftWrapEventHandler.processNewGiftWrap` (kept for the unlikely case where Marmot ever publishes a Welcome with no Seal layer) - `SealedRumorEventHandler.processNewSealedRumor` (the actual production path) — after unsealing, if the inner rumor is a WelcomeEvent it now goes straight to the shared flow instead of being passed to the unrouted event processor. Also added matching `MarmotDbg` logs in `processNewSealedRumor` so the seal-side trace is visible: id, unsealed inner kind/id, and the "detected Marmot WelcomeEvent inside seal" routing line. Compile-checked: `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL. --- .../loggedIn/DecryptAndIndexProcessor.kt | 145 +++++++++++------- 1 file changed, 92 insertions(+), 53 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 940cb2a6b4..62356c6ee0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -330,58 +330,7 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { - Log.d("MarmotDbg") { - "processMarmotWelcome: innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}…" - } - val manager = account.marmotManager - if (manager == null) { - Log.w("MarmotDbg") { "processMarmotWelcome: marmotManager is null — Marmot store probably failed to init" } - return - } - if (innerEvent !is WelcomeEvent) { - Log.w("MarmotDbg") { "processMarmotWelcome: inner is not WelcomeEvent (kind=${innerEvent.kind})" } - return - } - - val nostrGroupId = innerEvent.nostrGroupId() - if (nostrGroupId == null) { - Log.w("MarmotDbg") { "processMarmotWelcome: WelcomeEvent missing 'h' tag (nostrGroupId)" } - return - } - Log.d("MarmotDbg") { "processMarmotWelcome: invoking manager.processWelcome group=${nostrGroupId.take(8)}…" } - - val result = manager.processWelcome(innerEvent, nostrGroupId) - - when (result) { - is WelcomeResult.Joined -> { - Log.d("MarmotDbg") { - "processMarmotWelcome: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}" - } - - // Sync MIP-01 metadata from group extensions to chatroom - val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) - manager.syncMetadataTo(result.nostrGroupId, chatroom) - Log.d("MarmotDbg") { - "processMarmotWelcome: synced metadata name=${chatroom.displayName.value} " + - "members=${chatroom.memberCount.value} relays=${chatroom.relays.value}" - } - - // Notify any open MarmotGroupListScreen that a new invited - // group has appeared so it can re-render (the screen - // re-runs `loadGroupList` on every emission). Without this, - // newly-joined groups only show up after a manual refresh. - account.marmotGroupList.notifyGroupChanged(result.nostrGroupId) - - // Rotate KeyPackages if needed - if (result.needsKeyPackageRotation) { - account.publishMarmotKeyPackages() - } - } - - is WelcomeResult.Error -> { - Log.w("MarmotDbg") { "processMarmotWelcome: ERROR ${result.message}" } - } - } + processMarmotWelcomeFlow(innerEvent, account) } private suspend fun processExistingGiftWrap( @@ -396,6 +345,71 @@ class GiftWrapEventHandler( } } +/** + * Shared Marmot Welcome handler used by both [GiftWrapEventHandler] + * (in case a Welcome arrives directly inside a kind:1059 with no Seal + * layer) and [SealedRumorEventHandler] (the actual production path — + * Welcomes are wrapped GiftWrap → Seal → Welcome per + * [com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeGiftWrap]). + */ +private suspend fun processMarmotWelcomeFlow( + innerEvent: Event, + account: Account, +) { + Log.d("MarmotDbg") { + "processMarmotWelcomeFlow: innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}…" + } + val manager = account.marmotManager + if (manager == null) { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: marmotManager is null — Marmot store probably failed to init" } + return + } + if (innerEvent !is WelcomeEvent) { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: inner is not WelcomeEvent (kind=${innerEvent.kind})" } + return + } + + val nostrGroupId = innerEvent.nostrGroupId() + if (nostrGroupId == null) { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: WelcomeEvent missing 'h' tag (nostrGroupId)" } + return + } + Log.d("MarmotDbg") { "processMarmotWelcomeFlow: invoking manager.processWelcome group=${nostrGroupId.take(8)}…" } + + val result = manager.processWelcome(innerEvent, nostrGroupId) + + when (result) { + is WelcomeResult.Joined -> { + Log.d("MarmotDbg") { + "processMarmotWelcomeFlow: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}" + } + + // Sync MIP-01 metadata from group extensions to chatroom + val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + manager.syncMetadataTo(result.nostrGroupId, chatroom) + Log.d("MarmotDbg") { + "processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " + + "members=${chatroom.memberCount.value} relays=${chatroom.relays.value}" + } + + // Notify any open MarmotGroupListScreen that a new invited + // group has appeared so it can re-render (the screen + // re-runs `loadGroupList` on every emission). Without this, + // newly-joined groups only show up after a manual refresh. + account.marmotGroupList.notifyGroupChanged(result.nostrGroupId) + + // Rotate KeyPackages if needed + if (result.needsKeyPackageRotation) { + account.publishMarmotKeyPackages() + } + } + + is WelcomeResult.Error -> { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" } + } + } +} + class SealedRumorEventHandler( private val account: Account, private val cache: LocalCache, @@ -431,9 +445,34 @@ class SealedRumorEventHandler( eventNote: Note, publicNote: Note, ) { - val innerRumor = event.unsealOrNull(account.signer) ?: return + Log.d("MarmotDbg") { + "SealedRumorEventHandler.processNewSealedRumor: id=${event.id.take(8)}…" + } + val innerRumor = event.unsealOrNull(account.signer) + if (innerRumor == null) { + Log.w("MarmotDbg") { + "SealedRumorEventHandler.processNewSealedRumor: unseal returned null for ${event.id.take(8)}…" + } + return + } + Log.d("MarmotDbg") { + "SealedRumorEventHandler.processNewSealedRumor: unsealed innerKind=${innerRumor.kind} innerId=${innerRumor.id.take(8)}…" + } eventNote.event = event.copyNoContent() + + // Marmot Welcome: GiftWrap → Seal → WelcomeEvent. The Seal handler + // is the actual point at which we see the kind:444 inner. Route it + // straight to the shared flow — there's no normal LocalCache event + // handler for kind:444, so otherwise it would be silently dropped. + if (MarmotInboundProcessor.isWelcomeEvent(innerRumor)) { + Log.d("MarmotDbg") { + "SealedRumorEventHandler: detected Marmot WelcomeEvent inside seal — routing to processMarmotWelcomeFlow" + } + processMarmotWelcomeFlow(innerRumor, account) + return + } + cache.justConsume(innerRumor, null, true) cache.copyRelaysFromTo(publicNote, innerRumor) From 2265b0635d1643b056a33715f6fac6cac382e409 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 23:47:40 +0000 Subject: [PATCH 10/10] fix(marmot): discard legacy KeyPackage snapshots on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostic trace from the invitee shows the welcome flow working end-to-end (gift wrap → seal → processMarmotWelcomeFlow) but finally failing at MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle for eventId=41177359… — inviter referenced a KeyPackage we don't have private keys for The eventId the welcome carries IS on relays — the inviter just fetched it successfully in `fetchKeyPackageAndAddMember` — but on the invitee's device the `eventIdToSlot` map is empty, so the lookup misses. Root cause: the invitee's persisted KeyPackage snapshot is in v1 format (from before the eventId→slot index existed). `restoreFromStore` loaded v1 just fine, leaving bundles in place but the eventId map empty. Then `Account.ensureMarmotKeyPackagePublished` saw `hasActiveKeyPackages()` return true and *skipped republishing* — so the stale kind:30443 that the inviter just fetched has no matching mapping on the invitee. Fix: `restoreFromStore` now refuses to load any snapshot whose version is not the current `SNAPSHOT_VERSION` (2). On a v1 file it: 1. Logs a warning. 2. Deletes the on-disk snapshot via `store.delete()` so the next save writes fresh v2. 3. Returns without touching `activeBundles` / `pendingRotations` / `eventIdToSlot`. This means `hasActiveKeyPackages()` will return false right after restoreAll finishes, `ensureMarmotKeyPackagePublished` will generate + publish a fresh bundle (which `recordPublishedEventId` indexes by its real Nostr event id), the new kind:30443 replaces the old one on relays via d-tag addressability, and the next inviter's `fetchKeyPackageAndAddMember` will find the new event + the invitee will have the matching private keys to process its Welcome. The same defensive wipe also fires for a v2 snapshot that somehow carries bundles with an empty eventId map (corner case, crash during upgrade, etc.). Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`, `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL. --- .../KeyPackageRotationManager.kt | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index e40af16042..c10e039017 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -79,6 +79,14 @@ class KeyPackageRotationManager( * Restore previously persisted bundles + rotation state from [store]. * Call once at startup before any other use of this manager. * Safe to call when no store is configured (no-op in that case). + * + * NOTE: v1 snapshots are deliberately NOT loaded. v1 predates the + * `eventIdToSlot` index, so any bundles it holds would be unreachable + * to the Welcome path (which looks up bundles by Nostr event id). The + * cleanest upgrade is to discard the v1 state, let + * `Account.ensureMarmotKeyPackagePublished` regenerate + republish + * fresh KeyPackages, and rely on d-tag replacement on relays to + * replace the stale kind:30443 events. */ suspend fun restoreFromStore() { val store = store ?: return @@ -91,6 +99,36 @@ class KeyPackageRotationManager( } ?: return try { val decoded = decodeSnapshot(bytes) + if (decoded == null) { + Log.w("KeyPackageRotationManager") { + "Discarding legacy v1 KeyPackage snapshot — bundles will be regenerated and republished" + } + // Drop any state the caller may have set and force + // `ensureMarmotKeyPackagePublished` to publish fresh ones. + try { + store.delete() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to delete legacy snapshot: ${e.message}") + } + return + } + + // v2 snapshot: if bundles were restored but the eventId + // index is empty (upgrade corner case, or a corrupted save), + // the bundles are effectively unreachable — wipe them too + // so a fresh publish happens. + if (decoded.bundles.isNotEmpty() && decoded.eventIdToSlot.isEmpty()) { + Log.w("KeyPackageRotationManager") { + "Restored ${decoded.bundles.size} bundle(s) but no eventId→slot mapping — discarding, will republish" + } + try { + store.delete() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to delete stale snapshot: ${e.message}") + } + return + } + mutex.withLock { activeBundles.clear() activeBundles.putAll(decoded.bundles) @@ -146,13 +184,17 @@ class KeyPackageRotationManager( } /** - * Decode the persisted snapshot. + * Decode the persisted snapshot. Returns null if the on-disk version + * is older than the current [SNAPSHOT_VERSION] and should be discarded + * by the caller (see [restoreFromStore] for the rationale). */ - private fun decodeSnapshot(bytes: ByteArray): Snapshot { + private fun decodeSnapshot(bytes: ByteArray): Snapshot? { val reader = TlsReader(bytes) val version = reader.readUint16() - require(version == 1 || version == SNAPSHOT_VERSION) { - "Unsupported KeyPackage snapshot version: $version" + if (version != SNAPSHOT_VERSION) { + // Older / unknown snapshot version — tell the caller to + // discard it rather than loading partial state. + return null } val numBundles = reader.readUint32().toInt() val bundles = mutableMapOf() @@ -170,11 +212,8 @@ class KeyPackageRotationManager( repeat(numPending) { pending.add(reader.readOpaque2().decodeToString()) } - // eventId → slot map: only present in v2+. Older snapshots may - // not have this section, in which case the map starts empty and - // will be repopulated as KeyPackages are republished. val eventIdMap = mutableMapOf() - if (version >= SNAPSHOT_VERSION && reader.hasRemaining) { + if (reader.hasRemaining) { val numEventIds = reader.readUint32().toInt() repeat(numEventIds) { val eventId = reader.readOpaque2().decodeToString()