fix(marmot): persist group metadata + decrypted messages across restarts

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.
This commit is contained in:
Claude
2026-04-15 20:38:54 +00:00
parent 373d88efcf
commit 092bac6262
10 changed files with 420 additions and 12 deletions
@@ -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}",
)
}
}
}
}
}
}
@@ -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))
@@ -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:
* ```
* <rootDir>/mls_groups/<nostrGroupId>/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<String> =
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<String> {
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<String>(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<String>,
) {
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]+$")
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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) {
@@ -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<String> =
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.
@@ -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) {
@@ -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,
@@ -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<String>
/**
* Delete all persisted messages for a group (after leaving).
*
* @param nostrGroupId hex-encoded Nostr group ID
*/
suspend fun delete(nostrGroupId: String)
}