From 092bac6262540bb1112888e3f04735182a4ec6ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 20:38:54 +0000 Subject: [PATCH] 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 6aa8fddc7..b30507788 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 c4daa86e0..2e081d05c 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 000000000..56a32e2e4 --- /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 8cb6ebac8..91031ceee 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 c55d963f8..b019d7005 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 627cbc1d4..bb6fb6014 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 027c28535..209d930fa 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 d39c3fe8a..ec69b326c 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 c6eb34378..3e6b3b239 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 000000000..03fc3cede --- /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) +}