feat: Phase 6 — Marmot account integration & event routing

Wire Marmot MLS group messaging into the app's Account lifecycle,
event processing pipeline, and relay subscription system.

New files:
- AndroidMlsGroupStateStore: encrypted file-based MLS state storage
  using KeyStoreEncryption (AES/GCM backed by Android KeyStore)
- MarmotManager: central coordinator holding all Marmot components
  (MlsGroupManager, KeyPackageRotationManager, subscription/inbound/
  outbound processors, WelcomeSender)
- MarmotGroupEventsEoseManager: relay subscription manager for
  kind:445 GroupEvent filters via AccountFilterAssembler

Account integration:
- MarmotManager initialized during Account startup with restoreAll()
- Outbound methods: sendMarmotGroupMessage, addMarmotGroupMember,
  publishMarmotKeyPackage(s), createMarmotGroup
- GroupEvent (kind:445) and KeyPackageEvent (kind:30443) added to
  LocalCache.justConsumeInnerInner() dispatch

Event routing:
- GroupEventHandler: processes inbound kind:445 events through
  MarmotInboundProcessor, indexes decrypted inner events in LocalCache
- GiftWrapEventHandler extended: detects kind:444 WelcomeEvent after
  NIP-59 unwrap, routes to MarmotManager.processWelcome(), triggers
  KeyPackage rotation when needed
- WelcomeEvent enhanced with optional "h" tag for group ID routing

https://claude.ai/code/session_01W2LHazEt4E3W4hn8f7gWVW
This commit is contained in:
Claude
2026-04-05 19:54:56 +00:00
parent 4e56f68a8f
commit 8b56b3b3bf
12 changed files with 732 additions and 0 deletions
@@ -71,6 +71,7 @@ class MarmotWelcomeSender(
recipientPubKey: HexKey,
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
): WelcomeDelivery? {
val welcomeBytes = commitResult.welcomeBytes ?: return null
@@ -83,6 +84,7 @@ class MarmotWelcomeSender(
relays = relays,
recipientPubKey = recipientPubKey,
signer = signer,
nostrGroupId = nostrGroupId,
)
return WelcomeDelivery(
@@ -109,6 +111,7 @@ class MarmotWelcomeSender(
recipientPubKey: HexKey,
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
): WelcomeDelivery {
val welcomeBase64 = Base64.encode(welcomeBytes)
@@ -119,6 +122,7 @@ class MarmotWelcomeSender(
relays = relays,
recipientPubKey = recipientPubKey,
signer = signer,
nostrGroupId = nostrGroupId,
)
return WelcomeDelivery(
@@ -67,6 +67,12 @@ class WelcomeEvent(
/** Content encoding (must be "base64") */
fun encoding() = tags.welcomeEncoding()
/** Nostr group ID from the "h" tag (for recipient to identify the group) */
fun nostrGroupId(): HexKey? =
tags.firstNotNullOfOrNull { tag ->
if (tag.size >= 2 && tag[0] == "h") tag[1] else null
}
override fun isContentEncoded() = true
companion object {
@@ -77,12 +83,14 @@ class WelcomeEvent(
welcomeBase64: String,
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<WelcomeEvent>.() -> Unit = {},
) = eventTemplate(KIND, welcomeBase64, createdAt) {
keyPackageEventId(keyPackageEventId)
welcomeRelays(relays)
encoding()
nostrGroupId?.let { addUnique(arrayOf("h", it)) }
initializer()
}
}
@@ -57,6 +57,7 @@ object WelcomeGiftWrap {
relays: List<NormalizedRelayUrl>,
recipientPubKey: HexKey,
signer: NostrSigner,
nostrGroupId: HexKey? = null,
createdAt: Long = TimeUtils.now(),
): GiftWrapEvent {
// Step 1: Build the WelcomeEvent template and sign it
@@ -65,6 +66,7 @@ object WelcomeGiftWrap {
welcomeBase64 = welcomeBase64,
keyPackageEventId = keyPackageEventId,
relays = relays,
nostrGroupId = nostrGroupId,
createdAt = createdAt,
)
val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)