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 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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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))
|
||||
|
||||
+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,
|
||||
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)
|
||||
}
|
||||
|
||||
+6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -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) {
|
||||
|
||||
+36
@@ -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.
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
+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(
|
||||
nostrGroupId: HexKey,
|
||||
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