mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #2418 from vitorpamplona/claude/fix-group-metadata-persistence-kyTVP
Add persistent storage for Marmot KeyPackages and group messages
This commit is contained in:
@@ -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<NormalizedRelayUrl>,
|
||||
) {
|
||||
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<NormalizedRelayUrl>,
|
||||
) {
|
||||
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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -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))
|
||||
|
||||
+127
@@ -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:
|
||||
* ```
|
||||
* <rootDir>/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"
|
||||
}
|
||||
}
|
||||
+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]+$")
|
||||
}
|
||||
}
|
||||
+67
@@ -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:<groupHex>?account=<npub> — 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,
|
||||
|
||||
+25
@@ -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)
|
||||
|
||||
+8
-1
@@ -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<RelayBasedFilter> {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:<groupHex>?account=<npub>
|
||||
val groupHex =
|
||||
uri
|
||||
.removePrefix("marmot:")
|
||||
.substringBefore("?")
|
||||
.substringBefore("&")
|
||||
if (groupHex.matches(MARMOT_HEX)) {
|
||||
return Route.MarmotGroupChat(groupHex)
|
||||
}
|
||||
}
|
||||
|
||||
if (isWalletConnectRoute(uri)) {
|
||||
try {
|
||||
val url = UriParser(uri)
|
||||
|
||||
+33
-7
@@ -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)
|
||||
}
|
||||
|
||||
+151
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -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
|
||||
|
||||
+73
-40
@@ -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<Pair<HexKey, MarmotGroupChatroom>>()) }
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-3
@@ -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<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.
|
||||
@@ -272,7 +313,12 @@ class MarmotManager(
|
||||
relays = relays,
|
||||
)
|
||||
|
||||
return signer.sign(template)
|
||||
val signed = signer.sign<KeyPackageEvent>(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<KeyPackageEvent>(template)
|
||||
val signed = signer.sign<KeyPackageEvent>(template)
|
||||
keyPackageRotationManager.recordPublishedEventId(slot, signed.id)
|
||||
signed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -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<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
|
||||
|
||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||
@@ -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) {
|
||||
|
||||
+62
-2
@@ -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<HexKey, MarmotGroupChatroom>()
|
||||
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,
|
||||
|
||||
+33
-12
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -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()
|
||||
}
|
||||
+253
-4
@@ -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<String, KeyPackageBundle>()
|
||||
private val pendingRotations = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* 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<String, String>()
|
||||
|
||||
/**
|
||||
* 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<String, KeyPackageBundle>,
|
||||
val pending: Set<String>,
|
||||
val eventIdToSlot: Map<String, String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<String, KeyPackageBundle>()
|
||||
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<String>()
|
||||
repeat(numPending) {
|
||||
pending.add(reader.readOpaque2().decodeToString())
|
||||
}
|
||||
val eventIdMap = mutableMapOf<String, String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+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