diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6aa8fddc7a..5d01bf698c 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,8 @@ class Account( val client: INostrClient, val scope: CoroutineScope, val mlsGroupStateStore: MlsGroupStateStore? = null, + val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, + val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, ) : IAccount { private var userProfileCache: User? = null @@ -380,13 +382,13 @@ class Account( override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) override val marmotGroupList = com.vitorpamplona.amethyst.commons.model.marmotGroups - .MarmotGroupList() + .MarmotGroupList(signer.pubKey) val newNotesPreProcessor = EventProcessor(this, cache) 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, marmotKeyPackageStore) } val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) @@ -1735,11 +1737,27 @@ class Account( innerEvent: Event, groupRelays: Set, ) { + Log.d("MarmotDbg") { + "sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}… " + + "→ ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" + } val manager = marmotManager ?: return if (!isWriteable()) return val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent) + Log.d("MarmotDbg") { + "sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…" + } cache.justConsumeMyOwnEvent(outbound.signedEvent) + // Sending a message moves the group out of "New Requests" into + // "Known" — do this eagerly before relay round-trip so the UI + // updates immediately. + marmotGroupList.markAsKnown(nostrGroupId) + if (groupRelays.isEmpty()) { + Log.w("MarmotDbg") { + "sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped" + } + } client.publish(outbound.signedEvent, groupRelays) } @@ -1752,6 +1770,9 @@ class Account( nostrGroupId: HexKey, memberPubKey: HexKey, ): String { + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}…" + } val manager = marmotManager ?: return "Error: Marmot not initialized" if (!isWriteable()) return "Error: Account is read-only" @@ -1772,6 +1793,11 @@ class Account( .orEmpty() val fetchRelays = memberOutbox + myOutbox + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + + "(memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" + } + // Query across the combined relay set val filterMap = fetchRelays.associateWith { listOf(filter) } @@ -1781,15 +1807,24 @@ class Account( ) if (event == null) { + Log.w("MarmotDbg") { + "fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)" + } return "Error: No KeyPackage found for this user. They may not have published one yet." } + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…" + } + if (event !is com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent) { + Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: unexpected kind ${event.kind}" } return "Error: Unexpected event type received" } val keyPackageBase64 = event.keyPackageBase64() if (keyPackageBase64.isBlank()) { + Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" } return "Error: KeyPackage event has empty content" } @@ -1802,6 +1837,10 @@ class Account( // outbox — that's where we will publish them. val groupRelays = myOutbox.toList() + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}" + } + addMarmotGroupMember( nostrGroupId = nostrGroupId, memberPubKey = memberPubKey, @@ -1824,6 +1863,10 @@ class Account( keyPackageEventId: HexKey, groupRelays: List, ) { + Log.d("MarmotDbg") { + "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " + + "keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}" + } val manager = marmotManager ?: return if (!isWriteable()) return @@ -1836,36 +1879,54 @@ class Account( relays = groupRelays, ) + Log.d("MarmotDbg") { + "addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}… " + + "welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}" + } + // Publish commit first (critical ordering) + Log.d("MarmotDbg") { + "addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" + } client.publish(commitEvent.signedEvent, groupRelays.toSet()) // Then send the Welcome gift wrap to the new member. // - // We deliberately do NOT route this through - // computeRelayListToBroadcast(): its GiftWrap branch returns an - // empty relay set when the recipient has no NIP-17 kind:10050 and - // no cached NIP-65 inbox relays, which silently drops the welcome - // and leaves the invitee with no way to discover the group. - // - // Instead, mirror sendNip04PrivateMessage's delivery strategy: - // publish to our own outbox (so we keep a copy and the invitee - // may find it via a shared relay) unioned with the recipient's - // DM inbox relays — User.dmInboxRelays() already falls back - // through NIP-17 kind:10050 → NIP-65 read relays, which is where - // the invitee's AccountGiftWrapsEoseManager actually listens. + // Use the same delivery path that NIP-17 DMs (kind:1059) take — + // computeRelayListToBroadcast() — which has fallbacks for kind:10050 + // → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the + // invitee, so this path is the one we know works. We also union + // with our own outbox + the recipient's dmInboxRelays() as a + // belt-and-braces measure in case the cache hasn't been hydrated + // yet for this contact. if (welcomeDelivery != null) { + val computed = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) val recipientInbox = cache .getOrCreateUser(memberPubKey) .dmInboxRelays() .orEmpty() - val relayList = outboxRelays.flow.value + recipientInbox + val relayList = computed + outboxRelays.flow.value + recipientInbox + Log.d("MarmotDbg") { + "addMarmotGroupMember: welcome gift wrap relay sources " + + "computeRelayListToBroadcast=${computed.size} myOutbox=${outboxRelays.flow.value.size} " + + "recipientInbox=${recipientInbox.size} → union=${relayList.size}" + } if (relayList.isEmpty()) { - Log.w("Marmot") { - "addMarmotGroupMember: no relays to deliver welcome gift wrap to ${memberPubKey.take(8)}…" + Log.w("MarmotDbg") { + "addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped" + } + } else { + Log.d("MarmotDbg") { + "addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}… " + + "kind:${welcomeDelivery.giftWrapEvent.kind} → ${relayList.size} relay(s): ${relayList.map { it.url }}" } } client.publish(welcomeDelivery.giftWrapEvent, relayList) + } else { + Log.w("MarmotDbg") { + "addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!" + } } } @@ -1895,11 +1956,55 @@ class Account( if (!isWriteable()) return val relays = outboxRelays.flow.value.toList() + Log.d("MarmotDbg") { + "publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}" + } val event = manager.generateKeyPackageEvent(relays) + Log.d("MarmotDbg") { + "publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}…" + } cache.justConsumeMyOwnEvent(event) client.publish(event, outboxRelays.flow.value) } + /** + * Ensure the local user has at least one active KeyPackage bundle and + * a published KeyPackage event on relays. Called from [init] after + * Marmot state has been restored from disk. + * + * - If [KeyPackageRotationManager] already has an active bundle (from + * the persisted snapshot), we trust the previous session and do + * nothing. The matching kind:30443 should already be on relays from + * when the bundle was first generated. + * - Otherwise we generate a fresh bundle (which is now persisted to + * disk by [KeyPackageRotationManager.generateKeyPackage]) and + * publish the corresponding event. + * + * Best-effort: failures are logged but never propagated. We don't want + * a flaky relay or missing outbox config at startup to crash account + * initialization. + */ + private suspend fun ensureMarmotKeyPackagePublished() { + val manager = marmotManager ?: return + if (!isWriteable()) return + try { + val hasBundle = manager.hasActiveKeyPackages() + Log.d("MarmotDbg") { + "ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${signer.pubKey.take(8)}…" + } + if (hasBundle) { + return + } + Log.d("MarmotDbg") { + "ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now" + } + publishMarmotKeyPackage() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e) + } + } + /** * Check if a KeyPackage has been published, either locally generated * in this session or found in the local cache from a previous session. @@ -1927,6 +2032,9 @@ class Account( val manager = marmotManager ?: return if (!isWriteable()) return manager.createGroup(nostrGroupId) + // Creator owns the group — mark it as "known" immediately so it + // doesn't appear under "New Requests" before the first message. + marmotGroupList.markAsKnown(nostrGroupId) } /** @@ -1973,6 +2081,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 +2551,60 @@ class Account( if (marmotManager != null) { scope.launch(Dispatchers.IO) { marmotManager.restoreAll() - // Sync MIP-01 metadata from restored groups to chatrooms + + // Ensure the local user has a KeyPackage published to relays + // so other users can invite them to groups. Without this, + // freshly installed accounts (and accounts that never opened + // the Marmot Group screen) would never have an active + // KeyPackage on the relays, and any inviter trying to add + // them would fail with "No KeyPackage found". + // + // The KeyPackage bundle (private keys included) is persisted + // by KeyPackageRotationManager via marmotKeyPackageStore, so + // restoreAll() above has already restored any previously + // generated bundles. Only generate-and-publish if no active + // bundle exists in memory after restore. + ensureMarmotKeyPackagePublished() + + // Sync MIP-01 metadata from restored groups to chatrooms and + // re-hydrate decrypted messages from persistent storage. + // Note: Marmot MLS application messages cannot be re-decrypted + // 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) + // Force the kind:445 EOSE manager to re-poll its filter + // set so the restored group's per-`h`-tag subscription + // is actually sent to relays. Without this, restored + // groups would never receive new messages until the user + // explicitly created/joined another group. + marmotGroupList.notifyGroupChanged(groupId) + + val storedMessages = marmotManager.loadStoredMessages(groupId) + if (storedMessages.isNotEmpty()) { + Log.d("Account") { + "Restoring ${storedMessages.size} Marmot message(s) for group $groupId" + } + storedMessages.forEach { json -> + try { + val innerEvent = + com.vitorpamplona.quartz.nip01Core.core.Event + .fromJson(json) + val isNew = cache.justConsume(innerEvent, null, false) + val innerNote = cache.getOrCreateNote(innerEvent.id) + if (isNew) { + innerNote.event = innerEvent + } + marmotGroupList.restoreMessage(groupId, innerNote) + } catch (e: Exception) { + Log.w( + "Account", + "Failed to restore persisted Marmot message for $groupId: ${e.message}", + ) + } + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index c4daa86e07..baf2ee137d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -24,6 +24,8 @@ import android.content.ContentResolver import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore +import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.service.location.LocationState @@ -117,6 +119,30 @@ 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 + } + + val marmotKeyPackageStore = + try { + AndroidKeyPackageBundleStore(rootFilesDir()) + } catch (e: Exception) { + Log.e( + "AccountCacheState", + "Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", + e, + ) + null + } + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -134,6 +160,8 @@ class AccountCacheState( }, ), mlsGroupStateStore = mlsStore, + marmotMessageStore = marmotMessageStore, + marmotKeyPackageStore = marmotKeyPackageStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt new file mode 100644 index 0000000000..1a18bf77bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.marmot + +import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of [KeyPackageBundleStore] using file-based encrypted storage. + * + * Storage layout: + * ``` + * /marmot_keypackages/state — encrypted snapshot + * ``` + * + * The blob contains private key material — init keys, encryption keys, + * signature keys — that the MLS engine needs to process Welcome events + * received days or weeks after the corresponding KeyPackage was published. + * It is encrypted at rest with [KeyStoreEncryption] (AES/GCM via Android + * KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. + */ +class AndroidKeyPackageBundleStore( + private val rootDir: File, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) : KeyPackageBundleStore { + private val mutex = Mutex() + + init { + Log.d(TAG) { + "Initialized AndroidKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" + } + } + + private fun stateFile(): File = File(rootDir, "marmot_keypackages/state") + + override suspend fun save(snapshot: ByteArray) = + withContext(Dispatchers.IO) { + mutex.withLock { + val file = stateFile() + try { + file.parentFile?.mkdirs() + val encrypted = encryption.encrypt(snapshot) + atomicWrite(file, encrypted) + Log.d(TAG) { + "save(): wrote ${encrypted.size} encrypted bytes (${snapshot.size} plain)" + } + } catch (e: Exception) { + Log.e(TAG, "save() FAILED: ${e.message}", e) + throw e + } + } + } + + override suspend fun load(): ByteArray? = + withContext(Dispatchers.IO) { + val file = stateFile() + if (!file.exists()) { + Log.d(TAG) { "load(): no state file at ${file.absolutePath}" } + return@withContext null + } + try { + val encrypted = file.readBytes() + val decrypted = encryption.decrypt(encrypted) + Log.d(TAG) { + "load(): read ${encrypted.size} encrypted bytes → ${decrypted?.size ?: -1} plain" + } + decrypted + } catch (e: Exception) { + Log.e(TAG, "load() FAILED: ${e.message}", e) + null + } + } + + override suspend fun delete() { + withContext(Dispatchers.IO) { + mutex.withLock { + val file = stateFile() + if (file.exists()) { + Log.w(TAG) { "delete(): removing ${file.absolutePath}" } + file.delete() + } + } + } + } + + private fun atomicWrite( + target: File, + data: ByteArray, + ) { + val tempFile = File(target.parentFile, "${target.name}.tmp") + tempFile.writeBytes(data) + if (!tempFile.renameTo(target)) { + tempFile.copyTo(target, overwrite = true) + if (!tempFile.delete()) { + Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } + } + } + } + + companion object { + private const val TAG = "AndroidKeyPackageBundleStore" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt new file mode 100644 index 0000000000..56a32e2e4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.marmot + +import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of [MarmotMessageStore] using file-based encrypted storage. + * + * Stored alongside the [AndroidMlsGroupStateStore] data: + * ``` + * /mls_groups//messages — encrypted message log + * ``` + * + * The on-disk format (after decryption) is a sequence of length-prefixed + * UTF-8 entries: + * ``` + * uint32 count + * for each entry: + * uint32 length + * byte[length] utf8 + * ``` + * + * The whole blob is rewritten on each append (via atomic rename) — this + * keeps encryption simple (one GCM nonce per write) and is acceptable for + * conversation-scale histories. + */ +class AndroidMarmotMessageStore( + private val rootDir: File, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) : MarmotMessageStore { + private val writeMutex = Mutex() + + init { + Log.d(TAG) { + "Initialized AndroidMarmotMessageStore: rootDir=${rootDir.absolutePath}" + } + } + + private fun groupDir(nostrGroupId: String): File { + require(nostrGroupId.matches(HEX_PATTERN)) { + "Invalid nostrGroupId: must be a hex string" + } + return File(rootDir, "mls_groups/$nostrGroupId") + } + + private fun messagesFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "messages") + + override suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) = withContext(Dispatchers.IO) { + writeMutex.withLock { + try { + val existing = readAll(nostrGroupId).toMutableList() + existing.add(innerEventJson) + writeAll(nostrGroupId, existing) + Log.d(TAG) { + "appendMessage($nostrGroupId): now ${existing.size} message(s) persisted" + } + } catch (e: Exception) { + Log.e(TAG, "appendMessage($nostrGroupId) FAILED: ${e.message}", e) + throw e + } + } + } + + override suspend fun loadMessages(nostrGroupId: String): List = + withContext(Dispatchers.IO) { + try { + val messages = readAll(nostrGroupId) + Log.d(TAG) { + "loadMessages($nostrGroupId): loaded ${messages.size} message(s)" + } + messages + } catch (e: Exception) { + Log.e(TAG, "loadMessages($nostrGroupId) FAILED: ${e.message}", e) + emptyList() + } + } + + override suspend fun delete(nostrGroupId: String) { + withContext(Dispatchers.IO) { + writeMutex.withLock { + val file = messagesFile(nostrGroupId) + if (file.exists()) { + Log.w(TAG) { "delete($nostrGroupId): removing ${file.absolutePath}" } + file.delete() + } + } + } + } + + private fun readAll(nostrGroupId: String): List { + val file = messagesFile(nostrGroupId) + if (!file.exists()) return emptyList() + val encrypted = file.readBytes() + val plain = encryption.decrypt(encrypted) ?: return emptyList() + if (plain.size < 4) return emptyList() + + var offset = 0 + val count = + ((plain[offset++].toInt() and 0xFF) shl 24) or + ((plain[offset++].toInt() and 0xFF) shl 16) or + ((plain[offset++].toInt() and 0xFF) shl 8) or + (plain[offset++].toInt() and 0xFF) + + val result = ArrayList(count.coerceAtMost(MAX_MESSAGES)) + for (i in 0 until count) { + if (offset + 4 > plain.size) break + val len = + ((plain[offset++].toInt() and 0xFF) shl 24) or + ((plain[offset++].toInt() and 0xFF) shl 16) or + ((plain[offset++].toInt() and 0xFF) shl 8) or + (plain[offset++].toInt() and 0xFF) + if (len < 0 || offset + len > plain.size) break + result.add(plain.copyOfRange(offset, offset + len).decodeToString()) + offset += len + } + return result + } + + private fun writeAll( + nostrGroupId: String, + messages: List, + ) { + val file = messagesFile(nostrGroupId) + file.parentFile?.mkdirs() + + val encodedEntries = messages.map { it.encodeToByteArray() } + val totalSize = 4 + encodedEntries.sumOf { 4 + it.size } + val buffer = ByteArray(totalSize) + var offset = 0 + + val count = encodedEntries.size + buffer[offset++] = (count shr 24).toByte() + buffer[offset++] = (count shr 16).toByte() + buffer[offset++] = (count shr 8).toByte() + buffer[offset++] = count.toByte() + + for (entry in encodedEntries) { + val len = entry.size + buffer[offset++] = (len shr 24).toByte() + buffer[offset++] = (len shr 16).toByte() + buffer[offset++] = (len shr 8).toByte() + buffer[offset++] = len.toByte() + entry.copyInto(buffer, offset) + offset += len + } + + val encrypted = encryption.encrypt(buffer) + atomicWrite(file, encrypted) + } + + private fun atomicWrite( + target: File, + data: ByteArray, + ) { + val tempFile = File(target.parentFile, "${target.name}.tmp") + tempFile.writeBytes(data) + if (!tempFile.renameTo(target)) { + tempFile.copyTo(target, overwrite = true) + if (!tempFile.delete()) { + Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } + } + } + } + + companion object { + private const val TAG = "AndroidMarmotMessageStore" + private const val MAX_MESSAGES = 1_000_000 + private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 8216d9b68a..e2c6bb47bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -46,6 +46,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -168,6 +170,10 @@ class EventNotificationConsumer( is WakeUpEvent -> { wakeUpFor(innerEvent, innerNote, account) } + + is WelcomeEvent -> { + notify(innerEvent, account) + } } } } @@ -433,6 +439,67 @@ class EventNotificationConsumer( } } + private suspend fun notify( + event: WelcomeEvent, + account: Account, + ) { + Log.d(TAG, "New Marmot Welcome to Notify") + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + // a welcome we ourselves emitted + if (event.pubKey == account.signer.pubKey) return + + val nostrGroupId = event.nostrGroupId() ?: return + val manager = account.marmotManager ?: return + + // Best-effort: process the welcome here so the chatroom is hydrated + // before composing the notification body. The push-notification + // background path does NOT go through Account.eventProcessor, so + // without this the invitee would only join the group later, when + // they next open the app and the relay subscription redelivers. + if (!manager.isMember(nostrGroupId)) { + try { + val result = manager.processWelcome(event, nostrGroupId) + if (result is WelcomeResult.Joined) { + val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + manager.syncMetadataTo(result.nostrGroupId, chatroom) + account.marmotGroupList.notifyGroupChanged(result.nostrGroupId) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG) { "Failed to process Marmot Welcome from notification path: ${e.message}" } + } + } + + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: "a private group" + val inviter = LocalCache.getOrCreateUser(event.pubKey) + val inviterName = inviter.toBestDisplayName() + val inviterPicture = inviter.profilePicture() + + val accountNpub = + account.signer.pubKey + .hexToByteArray() + .toNpub() + // marmot:?account= — parsed by uriToRoute below. + val noteUri = "marmot:$nostrGroupId$ACCOUNT_QUERY_PARAM$accountNpub" + + notificationManager() + .sendDMNotification( + id = event.id, + messageBody = "You've been added to $groupName", + senderName = inviterName, + time = event.createdAt, + pictureUrl = inviterPicture, + uri = noteUri, + applicationContext = applicationContext, + accountNpub = accountNpub, + accountPictureUrl = account.userProfile().profilePicture(), + chatroomMembers = null, + ) + } + suspend fun decryptZapContentAuthor( event: LnZapRequestEvent, signer: NostrSigner, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 6946385521..5457125d48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -56,6 +57,9 @@ class MarmotGroupEventsEoseManager( // Per-group kind:445 filters — route each to the group's own relays val groupStates = manager.subscriptionManager.activeGroupIdsSnapshot() + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager.updateFilter: ${groupStates.size} active group(s), fallbackRelays=${fallbackRelays.size}" + } for (groupId in groupStates) { val filter = manager.subscriptionManager.let { sub -> @@ -77,6 +81,10 @@ class MarmotGroupEventsEoseManager( .normalizeOrNull(it) }?.toSet() val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager.updateFilter: group=${groupId.take(8)}… → ${relaysForGroup.size} relay(s) " + + "(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}" + } for (relay in relaysForGroup) { result.add(RelayBasedFilter(relay = relay, filter = filter)) } @@ -90,6 +98,9 @@ class MarmotGroupEventsEoseManager( } } + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager.updateFilter: emitting ${result.size} RelayBasedFilter(s)" + } return result } @@ -105,6 +116,20 @@ class MarmotGroupEventsEoseManager( invalidateFilters() } }, + // Critical: invalidate the kind:445 filter set whenever a + // group is joined (via Welcome processing), created, or + // left. Without this the per-group h-tag subscription is + // never sent to relays after the initial connect, so an + // invitee that just joined a group would never actually + // receive any of its messages until the next app restart. + key.account.scope.launch(Dispatchers.IO) { + key.account.marmotGroupList.groupListChanges.collect { changedGroupId -> + Log.d("MarmotDbg") { + "MarmotGroupEventsEoseManager: groupListChanges → ${changedGroupId.take(8)}… invalidating filters" + } + invalidateFilters() + } + }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index ab4de28752..54b9e0fd4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -45,7 +46,12 @@ class AccountGiftWrapsEoseManager( ): List { // Only loads DMs if the account is writeable return if (key.account.isWriteable()) { - key.account.dmRelays.flow.value.flatMap { relay -> + val relays = key.account.dmRelays.flow.value + Log.d("MarmotDbg") { + "AccountGiftWrapsEoseManager.updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… " + + "subscribing kind:1059 on ${relays.size} dmRelay(s): ${relays.map { it.url }}" + } + relays.flatMap { relay -> filterGiftWrapsToPubkey( relay = relay, pubkey = user(key).pubkeyHex, @@ -53,6 +59,7 @@ class AccountGiftWrapsEoseManager( ) } } else { + Log.d("MarmotDbg") { "AccountGiftWrapsEoseManager.updateFilter: account not writeable, skipping" } emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 746c69e69b..6871e1aca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -125,6 +125,10 @@ fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWit fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.startsWith("amethyst+walletconnect:dlnwc?value=") || uri.startsWith("amethyst+walletconnect://dlnwc?value=") +fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:") + +private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$") + fun uriToRoute( uri: String, account: Account, @@ -194,6 +198,18 @@ fun uriToRoute( } } + if (isMarmotGroupRoute(uri)) { + // marmot:?account= + val groupHex = + uri + .removePrefix("marmot:") + .substringBefore("?") + .substringBefore("&") + if (groupHex.matches(MARMOT_HEX)) { + return Route.MarmotGroupChat(groupHex) + } + } + if (isWalletConnectRoute(uri)) { try { val url = UriParser(uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8cb6ebac82..1613cecb74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1509,14 +1509,40 @@ class AccountViewModel( name: String, description: String, ) { - val currentMetadata = - account.marmotManager?.groupMetadata(nostrGroupId) - ?: throw IllegalStateException("Group metadata not found") + val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId) + // Stamp the inviter's outbox relays into the group metadata so that + // every member ends up with a single canonical relay set for kind:445 + // GroupEvents. Without this, both the inviter and the invitee fall + // back to their *own* home/outbox relays — which usually do not + // overlap, so kind:445 messages never reach the other side. The + // welcome carries the metadata, so the invitee learns the relays at + // join time. + val outboxRelayStrings = + account.outboxRelays.flow.value + .map { it.url } + val mergedRelays = + (currentMetadata?.relays.orEmpty() + outboxRelayStrings) + .distinct() val updatedMetadata = - currentMetadata.copy( - name = name, - description = description, - ) + if (currentMetadata != null) { + currentMetadata.copy( + name = name, + description = description, + relays = mergedRelays, + ) + } 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), + relays = mergedRelays, + ) + } val relays = marmotGroupRelays(nostrGroupId) account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index c55d963f86..62356c6ee0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -293,12 +293,27 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { - val innerGift = event.unwrapOrNull(account.signer) ?: return + Log.d("MarmotDbg") { + "GiftWrapEventHandler.processNewGiftWrap: id=${event.id.take(8)}… recipient=${event.recipientPubKey()?.take(8)}…" + } + val innerGift = event.unwrapOrNull(account.signer) + if (innerGift == null) { + Log.w("MarmotDbg") { + "GiftWrapEventHandler.processNewGiftWrap: unwrap returned null (decrypt failed) for id=${event.id.take(8)}…" + } + return + } + Log.d("MarmotDbg") { + "GiftWrapEventHandler.processNewGiftWrap: unwrapped innerKind=${innerGift.kind} innerId=${innerGift.id.take(8)}…" + } eventNote.event = event.copyNoContent() // Check if the unwrapped event is a Marmot WelcomeEvent (kind:444) if (MarmotInboundProcessor.isWelcomeEvent(innerGift)) { + Log.d("MarmotDbg") { + "GiftWrapEventHandler: detected Marmot WelcomeEvent — routing to processMarmotWelcome" + } processMarmotWelcome(innerGift, eventNote, publicNote) return } @@ -315,31 +330,7 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { - val manager = account.marmotManager ?: return - if (innerEvent !is WelcomeEvent) return - - val nostrGroupId = innerEvent.nostrGroupId() ?: return - - val result = manager.processWelcome(innerEvent, nostrGroupId) - - when (result) { - is WelcomeResult.Joined -> { - Log.d("GiftWrapEventHandler", "Joined Marmot group ${result.nostrGroupId}") - - // Sync MIP-01 metadata from group extensions to chatroom - val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) - manager.syncMetadataTo(result.nostrGroupId, chatroom) - - // Rotate KeyPackages if needed - if (result.needsKeyPackageRotation) { - account.publishMarmotKeyPackages() - } - } - - is WelcomeResult.Error -> { - Log.w("GiftWrapEventHandler") { "Failed to process Marmot Welcome: ${result.message}" } - } - } + processMarmotWelcomeFlow(innerEvent, account) } private suspend fun processExistingGiftWrap( @@ -354,6 +345,71 @@ class GiftWrapEventHandler( } } +/** + * Shared Marmot Welcome handler used by both [GiftWrapEventHandler] + * (in case a Welcome arrives directly inside a kind:1059 with no Seal + * layer) and [SealedRumorEventHandler] (the actual production path — + * Welcomes are wrapped GiftWrap → Seal → Welcome per + * [com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeGiftWrap]). + */ +private suspend fun processMarmotWelcomeFlow( + innerEvent: Event, + account: Account, +) { + Log.d("MarmotDbg") { + "processMarmotWelcomeFlow: innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}…" + } + val manager = account.marmotManager + if (manager == null) { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: marmotManager is null — Marmot store probably failed to init" } + return + } + if (innerEvent !is WelcomeEvent) { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: inner is not WelcomeEvent (kind=${innerEvent.kind})" } + return + } + + val nostrGroupId = innerEvent.nostrGroupId() + if (nostrGroupId == null) { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: WelcomeEvent missing 'h' tag (nostrGroupId)" } + return + } + Log.d("MarmotDbg") { "processMarmotWelcomeFlow: invoking manager.processWelcome group=${nostrGroupId.take(8)}…" } + + val result = manager.processWelcome(innerEvent, nostrGroupId) + + when (result) { + is WelcomeResult.Joined -> { + Log.d("MarmotDbg") { + "processMarmotWelcomeFlow: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}" + } + + // Sync MIP-01 metadata from group extensions to chatroom + val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + manager.syncMetadataTo(result.nostrGroupId, chatroom) + Log.d("MarmotDbg") { + "processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " + + "members=${chatroom.memberCount.value} relays=${chatroom.relays.value}" + } + + // Notify any open MarmotGroupListScreen that a new invited + // group has appeared so it can re-render (the screen + // re-runs `loadGroupList` on every emission). Without this, + // newly-joined groups only show up after a manual refresh. + account.marmotGroupList.notifyGroupChanged(result.nostrGroupId) + + // Rotate KeyPackages if needed + if (result.needsKeyPackageRotation) { + account.publishMarmotKeyPackages() + } + } + + is WelcomeResult.Error -> { + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" } + } + } +} + class SealedRumorEventHandler( private val account: Account, private val cache: LocalCache, @@ -389,9 +445,34 @@ class SealedRumorEventHandler( eventNote: Note, publicNote: Note, ) { - val innerRumor = event.unsealOrNull(account.signer) ?: return + Log.d("MarmotDbg") { + "SealedRumorEventHandler.processNewSealedRumor: id=${event.id.take(8)}…" + } + val innerRumor = event.unsealOrNull(account.signer) + if (innerRumor == null) { + Log.w("MarmotDbg") { + "SealedRumorEventHandler.processNewSealedRumor: unseal returned null for ${event.id.take(8)}…" + } + return + } + Log.d("MarmotDbg") { + "SealedRumorEventHandler.processNewSealedRumor: unsealed innerKind=${innerRumor.kind} innerId=${innerRumor.id.take(8)}…" + } eventNote.event = event.copyNoContent() + + // Marmot Welcome: GiftWrap → Seal → WelcomeEvent. The Seal handler + // is the actual point at which we see the kind:444 inner. Route it + // straight to the shared flow — there's no normal LocalCache event + // handler for kind:444, so otherwise it would be silently dropped. + if (MarmotInboundProcessor.isWelcomeEvent(innerRumor)) { + Log.d("MarmotDbg") { + "SealedRumorEventHandler: detected Marmot WelcomeEvent inside seal — routing to processMarmotWelcomeFlow" + } + processMarmotWelcomeFlow(innerRumor, account) + return + } + cache.justConsume(innerRumor, null, true) cache.copyRelaysFromTo(publicNote, innerRumor) @@ -464,49 +545,84 @@ class GroupEventHandler( eventNote: Note, publicNote: Note, ) { - val manager = account.marmotManager ?: return + Log.d("MarmotDbg") { + "GroupEventHandler.add: kind:445 id=${event.id.take(8)}… groupId=${event.groupId()?.take(8)}…" + } + val manager = account.marmotManager + if (manager == null) { + Log.w("MarmotDbg") { "GroupEventHandler.add: marmotManager is null" } + return + } - val groupId = event.groupId() ?: return - if (!manager.isMember(groupId)) return + val groupId = event.groupId() + if (groupId == null) { + Log.w("MarmotDbg") { "GroupEventHandler.add: kind:445 missing 'h' tag" } + return + } + if (!manager.isMember(groupId)) { + Log.w("MarmotDbg") { + "GroupEventHandler.add: not a member of group=${groupId.take(8)}… — dropping kind:445 ${event.id.take(8)}…" + } + return + } try { val result = manager.processGroupEvent(event) + Log.d("MarmotDbg") { + "GroupEventHandler.add: processGroupEvent returned ${result::class.simpleName} for group=${groupId.take(8)}…" + } when (result) { is GroupEventResult.ApplicationMessage -> { // Parse the inner event JSON and index it val innerEvent = Event.fromJson(result.innerEventJson) + Log.d("MarmotDbg") { + "GroupEventHandler.add: ApplicationMessage decrypted innerKind=${innerEvent.kind} " + + "innerId=${innerEvent.id.take(8)}… author=${innerEvent.pubKey.take(8)}…" + } if (cache.justConsume(innerEvent, null, false)) { val innerNote = cache.getOrCreateNote(innerEvent.id) innerNote.event = innerEvent // 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) + } else { + Log.d("MarmotDbg") { "GroupEventHandler.add: inner event already in cache (duplicate)" } } } is GroupEventResult.CommitProcessed -> { - Log.d("GroupEventHandler", "Commit processed for group ${result.groupId}, epoch=${result.newEpoch}") + Log.d("MarmotDbg") { + "GroupEventHandler.add: CommitProcessed group=${result.groupId.take(8)}… newEpoch=${result.newEpoch}" + } // Sync MIP-01 metadata after epoch advance (extensions may have changed) val chatroom = account.marmotGroupList.getOrCreateGroup(result.groupId) manager.syncMetadataTo(result.groupId, chatroom) } is GroupEventResult.CommitPending -> { - Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}") + Log.d("MarmotDbg") { + "GroupEventHandler.add: CommitPending group=${result.groupId.take(8)}… epoch=${result.epoch}" + } } is GroupEventResult.Duplicate -> { - Log.d("GroupEventHandler") { "Duplicate GroupEvent for group ${result.groupId}" } + Log.d("MarmotDbg") { "GroupEventHandler.add: Duplicate kind:445 for group=${result.groupId.take(8)}…" } } is GroupEventResult.Error -> { - Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" } + Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" } } } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("GroupEventHandler", "Failed to process GroupEvent", e) + Log.e("MarmotDbg", "GroupEventHandler.add: exception processing kind:445", e) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 627cbc1d47..77e42d7a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -68,11 +68,19 @@ fun CreateGroupScreen( try { val nostrGroupId = RandomInstance.bytes(32).toHexKey() accountViewModel.createMarmotGroup(nostrGroupId) - if (groupName.isNotBlank()) { - accountViewModel.account.marmotGroupList - .getOrCreateGroup(nostrGroupId) - .displayName.value = groupName - } + // Always commit an initial metadata extension so that + // (a) the name (if any) is persisted in MLS extensions + // and survives app restarts, + // (b) the inviter's outbox relays land in the group + // metadata so every member ends up with the same + // canonical relay set for kind:445 — without this, + // invitees would never receive the group's messages. + // Both are handled inside `updateMarmotGroupMetadata`. + accountViewModel.updateMarmotGroupMetadata( + nostrGroupId = nostrGroupId, + name = groupName.trim(), + description = "", + ) nav.nav(Route.MarmotGroupChat(nostrGroupId)) } catch (e: Exception) { isCreating = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 586723e493..6154ade01c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -42,7 +42,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -73,6 +75,7 @@ fun MarmotGroupListScreen( nav: INav, ) { var groupList by remember { mutableStateOf(listOf>()) } + var selectedTab by remember { mutableStateOf(0) } // Load group list LaunchedEffect(Unit) { @@ -86,16 +89,13 @@ fun MarmotGroupListScreen( } } - // Auto-publish KeyPackage if none exists yet - LaunchedEffect(Unit) { - if (!accountViewModel.hasPublishedKeyPackage()) { - try { - accountViewModel.publishMarmotKeyPackage() - } catch (_: Exception) { - // Silently retry on next screen visit - } - } - } + // KeyPackage publishing is handled at Account startup + // (Account.ensureMarmotKeyPackagePublished), so this screen no longer + // needs to do anything to make sure invitees can find a KeyPackage. + + val knownGroups = remember(groupList) { groupList.filter { it.second.ownerSentMessage } } + val newRequestGroups = remember(groupList) { groupList.filter { !it.second.ownerSentMessage } } + val visibleGroups = if (selectedTab == 0) knownGroups else newRequestGroups Scaffold( topBar = { @@ -117,37 +117,70 @@ fun MarmotGroupListScreen( } }, ) { padding -> - if (groupList.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "No groups yet", - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = "Create a group or wait for an invitation.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { + Text( + text = if (knownGroups.isEmpty()) "Known" else "Known (${knownGroups.size})", + ) + }, + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { + Text( + text = if (newRequestGroups.isEmpty()) "New Requests" else "New Requests (${newRequestGroups.size})", + ) + }, + ) } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - ) { - items(groupList, key = { it.first }) { (groupId, chatroom) -> - MarmotGroupListItem( - groupId = groupId, - chatroom = chatroom, - onClick = { - nav.nav(Route.MarmotGroupChat(groupId)) - }, - ) - HorizontalDivider() + + if (visibleGroups.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (selectedTab == 0) "No groups yet" else "No invitations", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Text( + text = + if (selectedTab == 0) { + "Create a group or accept an invitation." + } else { + "When someone adds you to a group it will show up here until you reply." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(visibleGroups, key = { it.first }) { (groupId, chatroom) -> + MarmotGroupListItem( + groupId = groupId, + chatroom = chatroom, + onClick = { + nav.nav(Route.MarmotGroupChat(groupId)) + }, + ) + HorizontalDivider() + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 027c285351..ef7fea4e1c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -29,12 +29,14 @@ import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender import com.vitorpamplona.quartz.marmot.OutboundGroupEvent import com.vitorpamplona.quartz.marmot.WelcomeDelivery import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils 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,9 +65,11 @@ import kotlin.io.encoding.ExperimentalEncodingApi class MarmotManager( val signer: NostrSigner, store: MlsGroupStateStore, + val messageStore: MarmotMessageStore? = null, + val keyPackageStore: KeyPackageBundleStore? = null, ) { val groupManager = MlsGroupManager(store) - val keyPackageRotationManager = KeyPackageRotationManager() + val keyPackageRotationManager = KeyPackageRotationManager(keyPackageStore) val subscriptionManager = MarmotSubscriptionManager(signer.pubKey) val inboundProcessor = MarmotInboundProcessor(groupManager, keyPackageRotationManager) val outboundProcessor = MarmotOutboundProcessor(groupManager) @@ -81,6 +85,9 @@ class MarmotManager( groupManager.restoreAll() val activeIds = groupManager.activeGroupIds() subscriptionManager.syncWithGroupManager(activeIds) + // Also restore previously-published KeyPackage bundles so that + // Welcomes referencing them remain processable across restarts. + keyPackageRotationManager.restoreFromStore() Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" } } catch (e: Exception) { Log.e("MarmotManager", "Failed to restore Marmot state", e) @@ -219,9 +226,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. @@ -272,7 +313,12 @@ class MarmotManager( relays = relays, ) - return signer.sign(template) + val signed = signer.sign(template) + // Welcome receivers identify the consumed KeyPackage by its Nostr + // event id (the MIP-02 "e" tag), not by the MLS reference hash, so + // remember the mapping right after we know the signed event id. + keyPackageRotationManager.recordPublishedEventId(dTagSlot, signed.id) + return signed } /** @@ -298,7 +344,9 @@ class MarmotManager( keyPackageRef = keyPackageRef, relays = relays, ) - signer.sign(template) + val signed = signer.sign(template) + keyPackageRotationManager.recordPublishedEventId(slot, signed.id) + signed } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index d39c3fe8ad..54b7f102ed 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -49,6 +49,14 @@ class MarmotGroupChatroom( var newestMessage: Note? = null val unreadCount = MutableStateFlow(0) + /** + * True if the local user has ever sent an application message in this + * group, OR explicitly created/owns it. Used by list UIs to split groups + * into "Known" vs "New Requests" — invitees that have not yet replied + * stay in "New Requests" until they participate. + */ + var ownerSentMessage: Boolean = false + private var changesFlow: WeakReference>> = WeakReference(null) fun changesFlow(): MutableSharedFlow> { @@ -81,6 +89,29 @@ class MarmotGroupChatroom( return false } + /** + * Add a message that is being restored from persistent storage on app + * startup. Behaves like [addMessageSync] but does NOT bump the unread + * count — restored messages were already seen by the user in a previous + * session. + */ + @Synchronized + fun restoreMessageSync(msg: Note): Boolean { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) + + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + changesFlow.get()?.tryEmit(ListChange.Addition(msg)) + return true + } + return false + } + @Synchronized fun removeMessageSync(msg: Note): Boolean { if (msg in messages) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index c6eb34378a..30d6930853 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -30,7 +30,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow * Tracks all Marmot MLS group chatrooms for an account. * Follows the same pattern as [com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList]. */ -class MarmotGroupList { +class MarmotGroupList( + val ownerPubKey: HexKey, +) { var rooms = LargeCache() private set @@ -44,11 +46,69 @@ class MarmotGroupList { msg: Note, ) { val chatroom = getOrCreateGroup(nostrGroupId) - if (chatroom.addMessageSync(msg)) { + val isSelfAuthored = msg.author?.pubkeyHex == ownerPubKey + // Use the quiet path for our own messages so the relay round-trip + // doesn't mark the user's own outgoing message as unread. + val added = + if (isSelfAuthored) { + chatroom.restoreMessageSync(msg) + } else { + chatroom.addMessageSync(msg) + } + if (added) { + if (isSelfAuthored) { + chatroom.ownerSentMessage = true + } _groupListChanges.tryEmit(nostrGroupId) } } + /** + * 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)) { + if (msg.author?.pubkeyHex == ownerPubKey) { + chatroom.ownerSentMessage = true + } + _groupListChanges.tryEmit(nostrGroupId) + } + } + + /** + * Mark a group as "known" by the local user — used right after the user + * creates a group, so the creator doesn't appear under "New Requests" + * until they post their first message. + */ + fun markAsKnown(nostrGroupId: HexKey) { + val chatroom = getOrCreateGroup(nostrGroupId) + if (!chatroom.ownerSentMessage) { + chatroom.ownerSentMessage = true + _groupListChanges.tryEmit(nostrGroupId) + } + } + + /** + * Notify subscribers that a group's state has changed (e.g., the invitee + * just joined via a Welcome and metadata was synced into the chatroom). + * Used when no message addition occurs but the list UI should refresh. + */ + fun notifyGroupChanged(nostrGroupId: HexKey) { + _groupListChanges.tryEmit(nostrGroupId) + } + + /** + * Whether the local user has ever sent a message in this group (or + * explicitly created it). Mirrors `ChatroomList.hasSentMessagesTo` for + * private DMs. + */ + fun hasSentMessagesTo(nostrGroupId: HexKey): Boolean = rooms.get(nostrGroupId)?.ownerSentMessage == true + fun removeMessage( nostrGroupId: HexKey, msg: Note, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index df7bbab55a..d4b46997b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.io.encoding.Base64 @@ -229,6 +228,10 @@ class MarmotInboundProcessor( nostrGroupId: HexKey, ): WelcomeResult = try { + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { + "MarmotInboundProcessor.processWelcome: group=${nostrGroupId.take(8)}… eventId=${welcomeEvent.id.take(8)}…" + } // Validate the caller-provided nostrGroupId matches the Welcome event's own h tag val eventGroupId = welcomeEvent.nostrGroupId() if (eventGroupId != null && eventGroupId != nostrGroupId) { @@ -242,26 +245,49 @@ class MarmotInboundProcessor( if (keyPackageEventId == null) { return WelcomeResult.Error("WelcomeEvent missing KeyPackage event ID tag") } + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { + "MarmotInboundProcessor.processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…" + } - // Find the KeyPackageBundle that was consumed - val bundle = - keyPackageRotationManager.findBundleByRef( - hexToBytes(keyPackageEventId), - ) ?: return WelcomeResult.Error( + // Find the KeyPackageBundle that was consumed. + // + // The Welcome's "e" tag carries the *Nostr event id* of the + // kind:30443 event (NOT the MLS reference hash), so we must + // resolve it via the eventId→slot index that + // [MarmotManager.generateKeyPackageEvent] populates after + // signing each KeyPackageEvent. + val bundle = keyPackageRotationManager.findBundleByEventId(keyPackageEventId) + if (bundle == null) { + com.vitorpamplona.quartz.utils.Log + .w("MarmotDbg") { + "MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle for eventId=${keyPackageEventId.take(8)}… " + + "— inviter referenced a KeyPackage we don't have private keys for. " + + "Either the bundle was generated in a previous session and never persisted, " + + "or this account never published this KeyPackage." + } + return WelcomeResult.Error( "No matching KeyPackageBundle found for event $keyPackageEventId", ) + } + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: bundle found — invoking groupManager.processWelcome" } // Join the group groupManager.processWelcome(nostrGroupId, welcomeBytes, bundle) + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { "MarmotInboundProcessor.processWelcome: groupManager.processWelcome succeeded for ${nostrGroupId.take(8)}…" } // Mark the KeyPackage as consumed — triggers rotation - keyPackageRotationManager.markConsumedByRef(hexToBytes(keyPackageEventId)) + keyPackageRotationManager.markConsumedByEventId(keyPackageEventId) WelcomeResult.Joined( nostrGroupId = nostrGroupId, needsKeyPackageRotation = keyPackageRotationManager.needsRotation(), ) } catch (e: Exception) { + com.vitorpamplona.quartz.utils.Log + .w("MarmotDbg", "MarmotInboundProcessor.processWelcome: exception ${e.message}", e) WelcomeResult.Error("Failed to process Welcome: ${e.message}", e) } @@ -455,9 +481,4 @@ class MarmotInboundProcessor( "Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)", ) } - - private fun hexToBytes(hex: HexKey?): ByteArray { - if (hex == null) return ByteArray(0) - return hex.hexToByteArray() - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt new file mode 100644 index 0000000000..edfcc6fe07 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageBundleStore.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mip00KeyPackages + +/** + * Encrypted local storage for the [KeyPackageRotationManager]'s state. + * + * KeyPackageBundles contain private key material (init key, encryption key, + * signature key) that the local user MUST retain on disk so they can process + * Welcome events the inviter sends days or weeks after publishing the + * KeyPackage. Without this storage, every app restart would discard the + * bundles and the user would have to re-publish a fresh KeyPackage — + * meanwhile, any inviter holding the old (now-orphaned) KeyPackage would be + * unable to add them to a group, because the matching private key is gone. + * + * Implementations MUST encrypt all data at rest. The persisted blob is a + * single opaque snapshot of the rotation manager state — both the active + * bundles map and the pending-rotation set are serialized together so they + * stay consistent across crashes. + */ +interface KeyPackageBundleStore { + /** + * Persist a snapshot of the rotation manager state. + * Overwrites any previously saved state. + * + * @param snapshot opaque encoded bytes from + * [KeyPackageRotationManager.snapshotBytes] + */ + suspend fun save(snapshot: ByteArray) + + /** + * Load a previously saved snapshot, or null if none exists. + */ + suspend fun load(): ByteArray? + + /** + * Delete the persisted state (e.g., when wiping the account). + */ + suspend fun delete() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index e030d4583a..c10e039017 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.mip00KeyPackages +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 @@ -30,6 +32,8 @@ import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -51,11 +55,189 @@ import kotlinx.coroutines.sync.withLock * Per MIP-00 spec, each user should maintain up to [KeyPackageUtils.MAX_SLOTS] * KeyPackage slots, rotating consumed ones promptly. */ -class KeyPackageRotationManager { +class KeyPackageRotationManager( + private val store: KeyPackageBundleStore? = null, +) { private val mutex = Mutex() private val activeBundles = mutableMapOf() private val pendingRotations = mutableSetOf() + /** + * Maps the Nostr event id (kind:30443) of a published KeyPackage to the + * d-tag slot whose bundle backs it. The welcome event references its + * consumed KeyPackage by Nostr event id (the MIP-02 "e" tag), but + * [activeBundles] is keyed by d-tag slot — so we need this side index + * to recover the matching bundle on the receive side. + * + * Populated by [recordPublishedEventId], which is called right after + * the kind:30443 event is signed (the event id is only known then). + * Also persisted in the snapshot so it survives app restart. + */ + private val eventIdToSlot = mutableMapOf() + + /** + * Restore previously persisted bundles + rotation state from [store]. + * Call once at startup before any other use of this manager. + * Safe to call when no store is configured (no-op in that case). + * + * NOTE: v1 snapshots are deliberately NOT loaded. v1 predates the + * `eventIdToSlot` index, so any bundles it holds would be unreachable + * to the Welcome path (which looks up bundles by Nostr event id). The + * cleanest upgrade is to discard the v1 state, let + * `Account.ensureMarmotKeyPackagePublished` regenerate + republish + * fresh KeyPackages, and rely on d-tag replacement on relays to + * replace the stale kind:30443 events. + */ + suspend fun restoreFromStore() { + val store = store ?: return + val bytes = + try { + store.load() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to load persisted KeyPackages: ${e.message}") + null + } ?: return + try { + val decoded = decodeSnapshot(bytes) + if (decoded == null) { + Log.w("KeyPackageRotationManager") { + "Discarding legacy v1 KeyPackage snapshot — bundles will be regenerated and republished" + } + // Drop any state the caller may have set and force + // `ensureMarmotKeyPackagePublished` to publish fresh ones. + try { + store.delete() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to delete legacy snapshot: ${e.message}") + } + return + } + + // v2 snapshot: if bundles were restored but the eventId + // index is empty (upgrade corner case, or a corrupted save), + // the bundles are effectively unreachable — wipe them too + // so a fresh publish happens. + if (decoded.bundles.isNotEmpty() && decoded.eventIdToSlot.isEmpty()) { + Log.w("KeyPackageRotationManager") { + "Restored ${decoded.bundles.size} bundle(s) but no eventId→slot mapping — discarding, will republish" + } + try { + store.delete() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to delete stale snapshot: ${e.message}") + } + return + } + + mutex.withLock { + activeBundles.clear() + activeBundles.putAll(decoded.bundles) + pendingRotations.clear() + pendingRotations.addAll(decoded.pending) + eventIdToSlot.clear() + eventIdToSlot.putAll(decoded.eventIdToSlot) + } + Log.d("KeyPackageRotationManager") { + "Restored ${decoded.bundles.size} active KeyPackage bundle(s), " + + "${decoded.pending.size} pending rotation, ${decoded.eventIdToSlot.size} eventId mapping(s)" + } + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to decode persisted KeyPackages: ${e.message}") + } + } + + private data class Snapshot( + val bundles: Map, + val pending: Set, + val eventIdToSlot: Map, + ) + + /** + * Encode the current rotation manager state to opaque bytes for + * persistence. Caller must hold the mutex. + */ + private fun snapshotBytesUnlocked(): ByteArray { + val writer = TlsWriter() + // version + writer.putUint16(SNAPSHOT_VERSION) + // active bundles + writer.putUint32(activeBundles.size.toLong()) + for ((slot, bundle) in activeBundles) { + writer.putOpaque2(slot.encodeToByteArray()) + writer.putOpaque4(bundle.keyPackage.toTlsBytes()) + writer.putOpaque2(bundle.initPrivateKey) + writer.putOpaque2(bundle.encryptionPrivateKey) + writer.putOpaque2(bundle.signaturePrivateKey) + } + // pending rotations + writer.putUint32(pendingRotations.size.toLong()) + for (slot in pendingRotations) { + writer.putOpaque2(slot.encodeToByteArray()) + } + // eventId → slot (added in v2) + writer.putUint32(eventIdToSlot.size.toLong()) + for ((eventId, slot) in eventIdToSlot) { + writer.putOpaque2(eventId.encodeToByteArray()) + writer.putOpaque2(slot.encodeToByteArray()) + } + return writer.toByteArray() + } + + /** + * Decode the persisted snapshot. Returns null if the on-disk version + * is older than the current [SNAPSHOT_VERSION] and should be discarded + * by the caller (see [restoreFromStore] for the rationale). + */ + private fun decodeSnapshot(bytes: ByteArray): Snapshot? { + val reader = TlsReader(bytes) + val version = reader.readUint16() + if (version != SNAPSHOT_VERSION) { + // Older / unknown snapshot version — tell the caller to + // discard it rather than loading partial state. + return null + } + val numBundles = reader.readUint32().toInt() + val bundles = mutableMapOf() + repeat(numBundles) { + val slot = reader.readOpaque2().decodeToString() + val kpBytes = reader.readOpaque4() + val keyPackage = MlsKeyPackage.decodeTls(TlsReader(kpBytes)) + val initPriv = reader.readOpaque2() + val encPriv = reader.readOpaque2() + val sigPriv = reader.readOpaque2() + bundles[slot] = KeyPackageBundle(keyPackage, initPriv, encPriv, sigPriv) + } + val numPending = reader.readUint32().toInt() + val pending = mutableSetOf() + repeat(numPending) { + pending.add(reader.readOpaque2().decodeToString()) + } + val eventIdMap = mutableMapOf() + if (reader.hasRemaining) { + val numEventIds = reader.readUint32().toInt() + repeat(numEventIds) { + val eventId = reader.readOpaque2().decodeToString() + val slot = reader.readOpaque2().decodeToString() + eventIdMap[eventId] = slot + } + } + return Snapshot(bundles, pending, eventIdMap) + } + + /** + * Persist the current state to [store]. Caller must hold the mutex. + * Best-effort: persistence failures are logged but don't propagate, so + * a failing disk write never breaks an in-flight Welcome / addMember. + */ + private suspend fun persistUnlocked() { + val store = store ?: return + try { + store.save(snapshotBytesUnlocked()) + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "Failed to persist KeyPackages: ${e.message}") + } + } + /** * Generate a new KeyPackage and its associated private bundle. * @@ -98,6 +280,7 @@ class KeyPackageRotationManager { val bundle = KeyPackageBundle(keyPackage, initKp.privateKey, encKp.privateKey, sigKp.privateKey) mutex.withLock { activeBundles[dTagSlot] = bundle + persistUnlocked() } return bundle } @@ -110,7 +293,13 @@ class KeyPackageRotationManager { /** * Find the bundle whose KeyPackage reference matches the given ref. - * Used when we receive a Welcome and need to find the matching bundle. + * Used when we receive a Welcome and need to find the matching bundle + * via the MLS-spec KeyPackageRef hash. + * + * NOTE: a Marmot Welcome's "e" tag actually carries the *Nostr event id* + * of the kind:30443 event, NOT the MLS reference hash, so the welcome + * receive path must use [findBundleByEventId] instead. This function + * remains for any callers that genuinely need the MLS-ref lookup. */ suspend fun findBundleByRef(keyPackageRef: ByteArray): KeyPackageBundle? = mutex.withLock { @@ -119,6 +308,35 @@ class KeyPackageRotationManager { } } + /** + * Find the bundle for a KeyPackage that was published as the given + * Nostr event id (kind:30443). This is the lookup used by Welcome + * processing — the "e" tag in a [WelcomeEvent] is the Nostr event id. + * + * Requires [recordPublishedEventId] to have been called for the slot + * after the kind:30443 event was signed. + */ + suspend fun findBundleByEventId(eventId: HexKey): KeyPackageBundle? = + mutex.withLock { + val slot = eventIdToSlot[eventId] ?: return@withLock null + activeBundles[slot] + } + + /** + * Record that the bundle for [dTagSlot] has been published as the + * kind:30443 event with [eventId]. Call this immediately after signing + * the event template (the Nostr event id is only known once the event + * has been signed). The mapping is persisted alongside the bundles so + * it survives app restart. + */ + suspend fun recordPublishedEventId( + dTagSlot: String, + eventId: HexKey, + ) = mutex.withLock { + eventIdToSlot[eventId] = dTagSlot + persistUnlocked() + } + /** * Mark a KeyPackage slot as consumed (used in a Welcome). * The slot will be included in [pendingRotationSlots] and should be @@ -127,7 +345,11 @@ class KeyPackageRotationManager { suspend fun markConsumed(dTagSlot: String) = mutex.withLock { activeBundles.remove(dTagSlot) + // Drop any eventId mappings that pointed at this slot. + val staleEventIds = eventIdToSlot.entries.filter { it.value == dTagSlot }.map { it.key } + staleEventIds.forEach { eventIdToSlot.remove(it) } pendingRotations.add(dTagSlot) + persistUnlocked() } /** @@ -140,11 +362,29 @@ class KeyPackageRotationManager { bundle.keyPackage.reference().contentEquals(keyPackageRef) } if (entry != null) { - activeBundles.remove(entry.key) - pendingRotations.add(entry.key) + val consumedSlot = entry.key + activeBundles.remove(consumedSlot) + val staleEventIds = eventIdToSlot.entries.filter { it.value == consumedSlot }.map { it.key } + staleEventIds.forEach { eventIdToSlot.remove(it) } + pendingRotations.add(consumedSlot) + persistUnlocked() } } + /** + * Mark a slot as consumed by Nostr event id (the value carried by the + * Welcome's "e" tag). Companion to [findBundleByEventId]. + */ + suspend fun markConsumedByEventId(eventId: HexKey) = + mutex.withLock { + val slot = eventIdToSlot[eventId] ?: return@withLock + activeBundles.remove(slot) + val staleEventIds = eventIdToSlot.entries.filter { it.value == slot }.map { it.key } + staleEventIds.forEach { eventIdToSlot.remove(it) } + pendingRotations.add(slot) + persistUnlocked() + } + /** * Get the d-tag slots that need rotation (KeyPackage was consumed). */ @@ -157,6 +397,7 @@ class KeyPackageRotationManager { suspend fun clearPendingRotation(dTagSlot: String) = mutex.withLock { pendingRotations.remove(dTagSlot) + persistUnlocked() } /** @@ -184,6 +425,7 @@ class KeyPackageRotationManager { val bundle = generateKeyPackage(identity, dTagSlot) mutex.withLock { pendingRotations.remove(dTagSlot) + persistUnlocked() } return bundle } @@ -233,5 +475,12 @@ class KeyPackageRotationManager { /** Proactive rotation after 7 days even if not consumed */ const val MAX_KEY_PACKAGE_AGE_SECONDS = 7L * 24 * 60 * 60 + + /** + * On-disk snapshot format version for [KeyPackageBundleStore]. + * v1: bundles + pendingRotations + * v2: + eventIdToSlot map (so welcome lookup by Nostr event id works) + */ + private const val SNAPSHOT_VERSION = 2 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt new file mode 100644 index 0000000000..03fc3cede3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls.group + +/** + * Encrypted local storage for decrypted Marmot inner event JSONs. + * + * Marmot MLS group messages are encrypted with per-message ratchet keys that + * advance as each message is decrypted. After the ratchet has advanced past + * a message, that ciphertext can no longer be decrypted — even by its + * original recipient. To make group history survive app restarts we must + * persist the *plaintext* inner events at the moment they are first + * decrypted, rather than relying on relay redelivery. + * + * Implementations MUST encrypt all data at rest — the stored blobs contain + * the contents of private group conversations. + * + * Each group's messages are stored independently, keyed by the hex-encoded + * Nostr group ID (the `h` tag value from MIP-01). + */ +interface MarmotMessageStore { + /** + * Append a decrypted inner event JSON to the group's persisted message log. + * Implementations should be tolerant to duplicate appends. + * + * @param nostrGroupId hex-encoded Nostr group ID + * @param innerEventJson the decrypted inner Nostr event JSON (e.g., kind:9 chat) + */ + suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) + + /** + * Load all persisted inner event JSONs for a group, in append order. + * + * @param nostrGroupId hex-encoded Nostr group ID + * @return list of inner event JSON strings, empty if none + */ + suspend fun loadMessages(nostrGroupId: String): List + + /** + * Delete all persisted messages for a group (after leaving). + * + * @param nostrGroupId hex-encoded Nostr group ID + */ + suspend fun delete(nostrGroupId: String) +}