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:
@@ -256,6 +256,7 @@ class Account(
|
|||||||
val client: INostrClient,
|
val client: INostrClient,
|
||||||
val scope: CoroutineScope,
|
val scope: CoroutineScope,
|
||||||
val mlsGroupStateStore: MlsGroupStateStore? = null,
|
val mlsGroupStateStore: MlsGroupStateStore? = null,
|
||||||
|
val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null,
|
||||||
) : IAccount {
|
) : IAccount {
|
||||||
private var userProfileCache: User? = null
|
private var userProfileCache: User? = null
|
||||||
|
|
||||||
@@ -386,7 +387,7 @@ class Account(
|
|||||||
|
|
||||||
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
|
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)
|
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
|
||||||
|
|
||||||
@@ -1973,6 +1974,11 @@ class Account(
|
|||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|
||||||
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
|
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)
|
client.publish(outbound.signedEvent, groupRelays)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2438,10 +2444,37 @@ class Account(
|
|||||||
if (marmotManager != null) {
|
if (marmotManager != null) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
marmotManager.restoreAll()
|
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 ->
|
marmotManager.activeGroupIds().forEach { groupId ->
|
||||||
val chatroom = marmotGroupList.getOrCreateGroup(groupId)
|
val chatroom = marmotGroupList.getOrCreateGroup(groupId)
|
||||||
marmotManager.syncMetadataTo(groupId, chatroom)
|
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}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -24,6 +24,7 @@ import android.content.ContentResolver
|
|||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
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.AndroidMlsGroupStateStore
|
||||||
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
|
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
|
||||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||||
@@ -117,6 +118,18 @@ class AccountCacheState(
|
|||||||
"Account ${signer.pubKey.take(8)}… using Marmot store: ${mlsStore::class.simpleName}"
|
"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(
|
return Account(
|
||||||
settings = accountSettings,
|
settings = accountSettings,
|
||||||
signer = signerWithClientTag,
|
signer = signerWithClientTag,
|
||||||
@@ -134,6 +147,7 @@ class AccountCacheState(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
mlsGroupStateStore = mlsStore,
|
mlsGroupStateStore = mlsStore,
|
||||||
|
marmotMessageStore = marmotMessageStore,
|
||||||
).also { newAccount ->
|
).also { newAccount ->
|
||||||
accounts.update { existingAccounts ->
|
accounts.update { existingAccounts ->
|
||||||
existingAccounts.plus(Pair(signer.pubKey, newAccount))
|
existingAccounts.plus(Pair(signer.pubKey, newAccount))
|
||||||
|
|||||||
+199
@@ -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]+$")
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-7
@@ -1509,14 +1509,25 @@ class AccountViewModel(
|
|||||||
name: String,
|
name: String,
|
||||||
description: String,
|
description: String,
|
||||||
) {
|
) {
|
||||||
val currentMetadata =
|
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
||||||
account.marmotManager?.groupMetadata(nostrGroupId)
|
|
||||||
?: throw IllegalStateException("Group metadata not found")
|
|
||||||
val updatedMetadata =
|
val updatedMetadata =
|
||||||
currentMetadata.copy(
|
if (currentMetadata != null) {
|
||||||
name = name,
|
currentMetadata.copy(
|
||||||
description = description,
|
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)
|
val relays = marmotGroupRelays(nostrGroupId)
|
||||||
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -482,6 +482,12 @@ class GroupEventHandler(
|
|||||||
|
|
||||||
// Track the message in the Marmot group chatroom
|
// Track the message in the Marmot group chatroom
|
||||||
account.marmotGroupList.addMessage(result.groupId, innerNote)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -69,9 +69,15 @@ fun CreateGroupScreen(
|
|||||||
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
|
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
|
||||||
accountViewModel.createMarmotGroup(nostrGroupId)
|
accountViewModel.createMarmotGroup(nostrGroupId)
|
||||||
if (groupName.isNotBlank()) {
|
if (groupName.isNotBlank()) {
|
||||||
accountViewModel.account.marmotGroupList
|
// Persist the name into the MLS GroupContext extensions
|
||||||
.getOrCreateGroup(nostrGroupId)
|
// via a GCE commit — otherwise it lives only in memory
|
||||||
.displayName.value = groupName
|
// 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))
|
nav.nav(Route.MarmotGroupChat(nostrGroupId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
+36
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
|
|||||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
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.MlsGroupManager
|
||||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
||||||
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
||||||
@@ -63,6 +64,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
class MarmotManager(
|
class MarmotManager(
|
||||||
val signer: NostrSigner,
|
val signer: NostrSigner,
|
||||||
store: MlsGroupStateStore,
|
store: MlsGroupStateStore,
|
||||||
|
val messageStore: MarmotMessageStore? = null,
|
||||||
) {
|
) {
|
||||||
val groupManager = MlsGroupManager(store)
|
val groupManager = MlsGroupManager(store)
|
||||||
val keyPackageRotationManager = KeyPackageRotationManager()
|
val keyPackageRotationManager = KeyPackageRotationManager()
|
||||||
@@ -219,9 +221,43 @@ class MarmotManager(
|
|||||||
// Now clean up group state
|
// Now clean up group state
|
||||||
groupManager.removeGroupState(nostrGroupId)
|
groupManager.removeGroupState(nostrGroupId)
|
||||||
subscriptionManager.unsubscribeGroup(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
|
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.
|
* Remove a member from a group.
|
||||||
* Returns the commit GroupEvent to publish.
|
* Returns the commit GroupEvent to publish.
|
||||||
|
|||||||
+23
@@ -81,6 +81,29 @@ class MarmotGroupChatroom(
|
|||||||
return false
|
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
|
@Synchronized
|
||||||
fun removeMessageSync(msg: Note): Boolean {
|
fun removeMessageSync(msg: Note): Boolean {
|
||||||
if (msg in messages) {
|
if (msg in messages) {
|
||||||
|
|||||||
+14
@@ -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(
|
fun removeMessage(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
msg: Note,
|
msg: Note,
|
||||||
|
|||||||
+66
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user