Extracts the Marketplace/Products functionality from the Discovery tab
into a dedicated standalone screen following the Pictures/Polls pattern.
The screen displays NIP-99 classifieds in a 2-column grid with its own
filter system, defaulting to Around Me (geolocation-based) filtering.
https://claude.ai/code/session_01PyrUZzSj7A3ZXucs7DTYbB
Add a new dedicated Articles screen accessible from the left drawer that
displays long-form content (NIP-23, kind 30023) with its own filters,
data sources, and FAB. The screen follows the same pattern as Pictures,
Polls, and Longs screens. Default filter is set to "All Follows".
- New route: Route.Articles with navigation and drawer entry
- ArticlesFeedFilter using LongTextNoteEvent with own follow list
- ArticlesFilterAssembler/SubAssembler reusing makeLongFormFilter
- ArticlesScreen with DisappearingScaffold, top bar filter, and FAB
- ArticlesFeedLoaded rendering via ChannelCardCompose/LongFormHeader
- Account settings for defaultArticlesFollowList with persistence
https://claude.ai/code/session_01QnP527Zq3xrtcLDmdx3LNf
Adds full MIP-04 (Encrypted Media) support for Marmot group chats:
Protocol layer (quartz):
- Mip04MediaEncryption: ChaCha20-Poly1305 AEAD with MLS exporter key
derivation (HKDF-Expand), random nonces, and AAD binding
- Mip04Cipher/Mip04NostrCipher: NostrCipher adapters for the existing
EncryptedBlobInterceptor download-and-decrypt pipeline
- Mip04IMetaTag: NIP-92 imeta tag builder/parser with MIP-04 fields
(url, m, filename, x, n, v=mip04-v2)
- MlsGroupManager.mediaExporterSecret(): MLS-Exporter("marmot",
"encrypted-media", 32)
Upload flow (amethyst):
- MarmotFileUploader: per-file Mip04NostrCipher → existing
UploadOrchestrator.uploadEncrypted pipeline (compression, stripping,
Blossom upload)
- MarmotFileSender: builds imeta tags and sends kind:9 inner events
through the MLS group message pipeline
- MarmotGroupMessageComposer: adds SelectFromGallery leading icon and
ChatFileUploadDialog (mirrors NIP-17 DM pattern)
Display flow:
- RenderMarmotEncryptedMedia: parses imeta v=mip04-v2 tags, derives
Mip04Cipher, registers in EncryptionKeyCache, renders via
ZoomableContentView (same path as NIP-17 encrypted files)
- MarmotGroupList.noteToGroupIndex: reverse index for mapping notes
back to their group ID (needed for exporter secret lookup)
- ChatMessageCompose NoteRow: routes MIP-04 events to the new renderer
https://claude.ai/code/session_01TckzZLpJdmE6p198DHBQ5i
Replace the "MLS Groups" FAB button on MessagesScreen with a "Create
Group" action that navigates directly to the group creation screen
(Route.CreateMarmotGroup) instead of the group list page. This aligns
with the plan to merge all marmot groups into the Messages screen's
Known and New Requests tabs.
Add marmot group messages to the notification screen so they appear
alongside regular DMs. This includes:
- Including marmot group messages in NotificationFeedFilter.feed()
- Accepting marmot group notes in acceptableEvent() via inGatherers check
- Converting marmot group notes to MessageSetCard in CardFeedContentState
- Routing marmot group notification clicks to the group chat screen
https://claude.ai/code/session_012wbykgUYHNVdHVNbDDnMUt
Fills out Quartz's NIP-34 implementation so clients can build and parse
every spec-defined event kind, not just repository announcements and issues.
Kind 30617 (Repository Announcement):
- Multi-value web/clone accessors and builders
- relays, maintainers, hashtags, earliestUniqueCommit, isPersonalFork
- New tag classes: RelaysTag, MaintainersTag, EucTag
- New build() overload covering every optional tag
Kind 30618 (Repository State): new GitRepositoryStateEvent with
refs/heads, refs/tags, HEAD, and a RefTag/HeadTag pair.
Kind 1617 (Patch): rewrite builder to use the eventTemplate DSL with
required a/r/p tags, optional commit/parent-commit/commit-pgp-sig/
committer/root/root-revision markers, and a reply() helper that adds
NIP-10 marked e-tags pointing at a prior patch.
Kind 1618/1619 (Pull Request + Update): new GitPullRequestEvent and
GitPullRequestUpdateEvent with c/clone/branch-name/merge-base tags and
NIP-22 E/P parent references for updates.
Kinds 1630/1631/1632/1633 (Status Open/Applied/Closed/Draft): new
GitStatusEvent base + four subclasses sharing a GitStatusBuilders DSL.
Applied status carries merge-commit, applied-as-commits, and q tags for
the specific patch events that were merged.
Kind 10317 (User Grasp List): new UserGraspListEvent replaceable list of
g tags (websocket URLs in preference order) for discovering grasp
hosting servers.
NIP-51 Kind 10017 (Git Authors List): now uses a dedicated GitAuthorTag
class with optional petname (NIP-02 follow-list 4th element) instead of
the mute-list UserTag, so round-tripping follow lists no longer drops
petnames.
All new event kinds are registered in EventFactory. Spotless-clean and
compiles via :quartz:compileKotlinJvm.
https://claude.ai/code/session_018Fz3AeEdGkeFoeHBKuMwbH
The diagnostic trace from the invitee shows the welcome flow working
end-to-end (gift wrap → seal → processMarmotWelcomeFlow) but finally
failing at
MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle
for eventId=41177359… — inviter referenced a KeyPackage we don't
have private keys for
The eventId the welcome carries IS on relays — the inviter just
fetched it successfully in `fetchKeyPackageAndAddMember` — but on the
invitee's device the `eventIdToSlot` map is empty, so the lookup
misses. Root cause: the invitee's persisted KeyPackage snapshot is
in v1 format (from before the eventId→slot index existed).
`restoreFromStore` loaded v1 just fine, leaving bundles in place but
the eventId map empty. Then `Account.ensureMarmotKeyPackagePublished`
saw `hasActiveKeyPackages()` return true and *skipped republishing*
— so the stale kind:30443 that the inviter just fetched has no
matching mapping on the invitee.
Fix: `restoreFromStore` now refuses to load any snapshot whose
version is not the current `SNAPSHOT_VERSION` (2). On a v1 file it:
1. Logs a warning.
2. Deletes the on-disk snapshot via `store.delete()` so the next
save writes fresh v2.
3. Returns without touching `activeBundles` / `pendingRotations` /
`eventIdToSlot`.
This means `hasActiveKeyPackages()` will return false right after
restoreAll finishes, `ensureMarmotKeyPackagePublished` will generate
+ publish a fresh bundle (which `recordPublishedEventId` indexes by
its real Nostr event id), the new kind:30443 replaces the old one on
relays via d-tag addressability, and the next inviter's
`fetchKeyPackageAndAddMember` will find the new event + the invitee
will have the matching private keys to process its Welcome.
The same defensive wipe also fires for a v2 snapshot that somehow
carries bundles with an empty eventId map (corner case, crash during
upgrade, etc.).
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
User reported the only logs Charlie's device produced when Bob added
him to a group were:
GiftWrapEventHandler.processNewGiftWrap: id=40e2073c… recipient=9a960f0b…
GiftWrapEventHandler.processNewGiftWrap: unwrapped innerKind=13 innerId=ffda2949…
That kind=13 is the smoking gun. The previous code in
`processNewGiftWrap` checked `isWelcomeEvent(innerGift)` directly on
the gift-wrap-unwrapped result, expecting kind:444. But Marmot
`WelcomeGiftWrap.wrapForRecipient` actually produces a three-layer
envelope:
GiftWrap (1059) → SealedRumor (13) → WelcomeEvent (444)
So `event.unwrapOrNull(signer)` returns the kind:13 Seal, not the
WelcomeEvent. The previous check fell through to `eventProcessor.
consumeEvent`, which dispatched to `SealedRumorEventHandler`, which
unsealed correctly to a kind:444 — and then handed it back to
`eventProcessor.consumeEvent` again. There's no LocalCache event
handler registered for kind:444, so the WelcomeEvent was silently
dropped on every invitee.
Fix: extract the welcome-handling code from `GiftWrapEventHandler.
processMarmotWelcome` into a top-level `processMarmotWelcomeFlow`
helper, and call it from BOTH:
- `GiftWrapEventHandler.processNewGiftWrap` (kept for the unlikely
case where Marmot ever publishes a Welcome with no Seal layer)
- `SealedRumorEventHandler.processNewSealedRumor` (the actual
production path) — after unsealing, if the inner rumor is a
WelcomeEvent it now goes straight to the shared flow instead of
being passed to the unrouted event processor.
Also added matching `MarmotDbg` logs in `processNewSealedRumor` so
the seal-side trace is visible: id, unsealed inner kind/id, and the
"detected Marmot WelcomeEvent inside seal" routing line.
Compile-checked: `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
While adding diagnostic logs to trace why invitees were receiving
nothing on Marmot group adds, I found the actual root cause: a
fundamental hash mismatch between the value the Welcome event carries
and the value the receiver looks up by.
### The bug
A Marmot WelcomeEvent (kind:444) carries a tag `["e", <eventId>]`
referencing the kind:30443 KeyPackage event that was consumed — see
`KeyPackageEventTag` and `MarmotWelcomeSender.wrapWelcome`. That
`<eventId>` is the *Nostr event id* (a hash of the signed event JSON).
`MarmotInboundProcessor.processWelcome` then called
`keyPackageRotationManager.findBundleByRef(hexToBytes(eventId))`,
which compares each stored bundle's `keyPackage.reference()` —
**the MLS-spec KeyPackageRef, an entirely different hash computed by
`MlsCryptoProvider.refHash("MLS 1.0 KeyPackage Reference", encoded)`
over the TLS-encoded KeyPackage**.
Those two values are never equal, so the lookup ALWAYS missed and
every invitee path returned `WelcomeResult.Error("No matching
KeyPackageBundle found")`. Combined with the previous in-memory-only
storage, this is why nothing ever appeared on the invitee's screen.
### The fix
`KeyPackageRotationManager` now also indexes bundles by the Nostr
event id of the corresponding kind:30443 event:
- `eventIdToSlot: Map<HexKey, String>` — populated by
`recordPublishedEventId(slot, eventId)`, called from
`MarmotManager.generateKeyPackageEvent` immediately after signing
the event template (which is when the event id is first known).
- `findBundleByEventId(eventId)` — looks up the slot via the new
index, then returns the bundle.
- `markConsumedByEventId(eventId)` — symmetric consume-by-event-id
for the welcome receive path.
- The persisted snapshot format is bumped from v1 → v2 to include
the eventId map. v1 snapshots are still readable (loaded as if
the eventId map were empty); republishing a KeyPackage will refill
it. Also cleans the index when slots are consumed.
`MarmotInboundProcessor.processWelcome` now uses
`findBundleByEventId(keyPackageEventId)` instead of
`findBundleByRef(hexToBytes(...))`, and `markConsumedByEventId` for
the consume call. The dead `hexToBytes` helper + import are removed.
### Diagnostic logging
Added `MarmotDbg`-tagged logs across the entire add-member / send /
receive chain so the user can `adb logcat -s MarmotDbg` to see
exactly what's being sent and what's being received:
- `Account.fetchKeyPackageAndAddMember` — querying relays, KP found
/ not found, kind, author
- `Account.addMarmotGroupMember` — commit publish target, welcome
delivery presence, full relay union with sources
- `Account.sendMarmotGroupMessage` — group, inner kind, target relays
- `Account.publishMarmotKeyPackage` + `ensureMarmotKeyPackagePublished`
— publish target relays, signed event id
- `DecryptAndIndexProcessor.processNewGiftWrap` — gift wrap unwrap
result, inner kind, route to Marmot welcome handler
- `DecryptAndIndexProcessor.processMarmotWelcome` — manager null,
WelcomeResult, synced metadata, group id
- `DecryptAndIndexProcessor.GroupEventHandler.add` — kind:445 arrival,
membership check, processGroupEvent result, decrypt path
- `MarmotInboundProcessor.processWelcome` — eventId lookup,
bundle-not-found details, MLS join success
- `MarmotGroupEventsEoseManager.updateFilter` — active groups,
per-group relay set, fallback usage, total emitted filters
- `AccountGiftWrapsEoseManager.updateFilter` — pubkey + dmRelay set
used for kind:1059 subscription
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
The user reported that NIP-17 DMs from Bob to Charlie arrive instantly,
but Marmot Welcomes and group messages produce nothing on Charlie's
device. Three independent bugs combined to make Marmot invisible on
the receiving side.
### 1. kind:445 EOSE filter never invalidated when joining a group
`MarmotGroupEventsEoseManager` only re-polled its filter set when
`account.homeRelays.flow` emitted. So an invitee that just processed
a Welcome got the new group added to `MarmotSubscriptionManager` and
to `marmotGroupList`, but the per-`h`-tag kind:445 subscription was
never actually sent to the relays — the EOSE manager kept using the
filter snapshot from app start. The same was true for restored groups
on every restart.
Fix: add a second job in `MarmotGroupEventsEoseManager.newSub` that
collects `account.marmotGroupList.groupListChanges` and calls
`invalidateFilters()`. `Account.init`'s restore loop now also fires
`marmotGroupList.notifyGroupChanged(groupId)` for every restored
group so the EOSE manager picks them up at startup.
### 2. Group metadata had no relays — both sides used different sets
`MarmotGroupData.relays` was always empty. `marmotGroupRelays` and
`MarmotGroupEventsEoseManager` both fall back to "the local user's
own relays" when the metadata has none — so Bob published kind:445 to
Bob's outbox while Charlie subscribed on Charlie's home relays. The
two only overlap by accident.
Fix: `AccountViewModel.updateMarmotGroupMetadata` now stamps the
inviter's outbox relays into the GCE proposal (merged with any
existing relays). The Welcome carries the GroupContext extensions, so
the invitee learns the same canonical relay set at join time.
`CreateGroupScreen` now always issues the metadata commit (even when
the user left the name blank) so this stamping happens for every new
group.
### 3. Welcome gift wrap delivery used a different relay path than NIP-17
`addMarmotGroupMember` had its own ad-hoc relay fallback chain
(`outboxRelays + cache.getOrCreateUser(...).dmInboxRelays()`) and
explicitly avoided `computeRelayListToBroadcast`. The comment claimed
that path returned an empty set in some cases, but the actual code in
`computeRelayListToBroadcast(GiftWrapEvent)` falls back through
kind:10050 → NIP-65 read → relay hints — exactly the same chain that
NIP-17 DMs use, which empirically reach the recipient.
Fix: union `computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)`
with the previous outbox + dmInboxRelays set so the welcome takes the
same route NIP-17 DMs do, plus belt-and-braces fallbacks. Added a log
line so we can tell from logcat exactly how many relays the welcome
went out on.
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
When user A wants to invite user B to a Marmot group, A must fetch B's
published KeyPackage (kind:30443) from relays. If B has never had one
published, A's `fetchKeyPackageAndAddMember` returns "No KeyPackage
found" and the invite silently fails — which matches the user's
report that "creating a new group with another user, nothing happens
for that user". Two fixes:
### 1. Persist KeyPackageBundles to disk
`KeyPackageRotationManager` was 100% in-memory: every restart wiped
both the active bundles (with their private init/encryption/signature
keys) AND the pending-rotation set. The relay echo of the kind:30443
event is meaningless without the matching private keys, so once a
session ended the user could no longer process Welcome events
referencing the published KeyPackage.
This change adds a quartz `KeyPackageBundleStore` interface and an
Android `AndroidKeyPackageBundleStore` implementation backed by the
same `KeyStoreEncryption` (AES/GCM via Android KeyStore) used by
`AndroidMlsGroupStateStore`. Storage layout:
<rootDir>/marmot_keypackages/state — encrypted snapshot
`KeyPackageRotationManager` accepts the store via constructor, encodes
the `(activeBundles, pendingRotations)` pair into a versioned TLS
snapshot, and persists after every state-mutating call:
`generateKeyPackage`, `markConsumed`, `markConsumedByRef`,
`clearPendingRotation`, `rotateSlot`. A new `restoreFromStore()`
method is invoked from `MarmotManager.restoreAll()` at startup.
The store is wired through:
MarmotManager(... keyPackageStore)
→ Account(... marmotKeyPackageStore)
→ AccountCacheState (constructs AndroidKeyPackageBundleStore)
### 2. Ensure a published KeyPackage at app startup
`Account.init` now calls a new private `ensureMarmotKeyPackagePublished`
right after `marmotManager.restoreAll()`. If no active bundle exists
in memory after the restore (i.e., the user never published one), it
generates a fresh bundle (which the rotation manager auto-persists)
and publishes the corresponding `KeyPackageEvent` to the user's
outbox relays. Best-effort — failures are logged and swallowed so a
flaky relay or missing outbox config at startup doesn't break account
init.
The redundant `LaunchedEffect` in `MarmotGroupListScreen` that did the
same thing on first screen visit is now removed: KeyPackage publishing
is no longer tied to the user navigating to the group list.
This means freshly installed accounts (and any account that had never
opened the Marmot Groups screen) will now have a published KeyPackage
on relays as soon as the account loads, so other users can actually
invite them.
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
- `MarmotGroupList.addMessage` now routes self-authored messages
through `restoreMessageSync` (the no-unread-bump path) so the
relay round-trip of a message the user just sent doesn't show up
as an unread bold entry in the group list and doesn't bump the
unread badge counter.
- `MarmotGroupListScreen` empty state now has 32dp horizontal
padding and centered text alignment so the "No invitations" /
"No groups yet" copy doesn't run into the screen edges.
When user A invites user B to a Marmot/MLS group, B previously had no
visible signal that they had been added: no notification fired, the
group screen was a single flat list with no notion of "this group was
not started by me", and the in-memory chatroom was created but not
broadcast to the list UI. Three related changes here.
1. **Invitee Welcome notification.** `EventNotificationConsumer` now
handles `WelcomeEvent` (kind:444) inside the unwrapped gift wrap.
The push-notification background path does NOT go through
`Account.eventProcessor`, so we explicitly call
`manager.processWelcome` here on a best-effort basis and then
`syncMetadataTo` so the notification body can include the actual
group name. A new `marmot:<groupHex>?account=<npub>` deep link
scheme is parsed by `uriToRoute` so tapping the notification opens
the group chat directly.
2. **Known vs New Requests split for Marmot groups.**
`MarmotGroupChatroom` gains an `ownerSentMessage` flag mirroring
the private-DM `Chatroom` field. `MarmotGroupList` now takes the
account's `ownerPubKey` and flips that flag whenever it sees a
message authored by the local user, both in the live `addMessage`
path and in `restoreMessage` on startup. Two helpers,
`markAsKnown` and `notifyGroupChanged`, let the Account layer
move groups out of "New Requests" eagerly:
- `Account.createMarmotGroup` marks the new group known (the
creator obviously knows their own group)
- `Account.sendMarmotGroupMessage` marks known immediately on
send, before the relay round-trip
- `DecryptAndIndexProcessor.processMarmotWelcome` now fires
`notifyGroupChanged` after a successful join so an open
`MarmotGroupListScreen` re-renders to show the new invite
`MarmotGroupListScreen` now has a `PrimaryTabRow` with "Known" and
"New Requests" tabs and partitions the loaded groups by the new
flag.
3. **Group metadata hydration on the invitee.** Verified that the
existing `processMarmotWelcome` already calls `syncMetadataTo` on
the chatroom after `MarmotManager.processWelcome` succeeds, so
group name / description / admins / members / relays land in the
chatroom right after a Welcome is processed. Combined with (2)'s
`notifyGroupChanged` emit, the list UI now refreshes immediately.
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:commons:compileAndroidMain`, `:amethyst:compilePlayDebugKotlin` —
all BUILD SUCCESSFUL.
Two related Marmot/MLS group bugs were causing data loss across app
restarts:
1. **Group name disappears, edit screen shows "Group Metadata Not Found".**
`CreateGroupScreen` was setting `chatroom.displayName.value` directly
in memory and never writing the name into the MLS GroupContext
extensions. After restart `MarmotManager.syncMetadataTo` had nothing
to read from, and `EditGroupInfoScreen` blew up because
`AccountViewModel.updateMarmotGroupMetadata` required existing
metadata to copy from.
Now `CreateGroupScreen` issues a real GCE commit through
`updateMarmotGroupMetadata` so the name is persisted in the MLS
extensions on disk. `AccountViewModel.updateMarmotGroupMetadata`
tolerates missing prior metadata by constructing a fresh
`MarmotGroupData` (creator as admin), so older groups can also be
recovered by editing them. `Account.updateMarmotGroupMetadata` now
calls `syncMetadataTo` after the local commit so the chatroom UI
reflects the new name without waiting for the relay round-trip.
2. **Messages disappear after restart.** `MarmotGroupChatroom.messages`
was purely in-memory. Marmot/MLS application messages cannot be
re-decrypted once the ratchet has advanced, so relay redelivery is
not enough to restore history — the plaintext must be captured at
first decryption and persisted.
This change introduces `MarmotMessageStore` (quartz interface) and
`AndroidMarmotMessageStore` (encrypted, file-based, sharing the same
KeyStoreEncryption used for MLS state). `MarmotManager` now exposes
`persistDecryptedMessage` / `loadStoredMessages` and clears the log
on `leaveGroup`. `GroupEventHandler` persists each new application
message after it has been added to the chatroom. On startup,
`Account.init` loads any stored messages for restored groups and
re-hydrates the chatroom via a new `restoreMessageSync` helper that
does not bump the unread counter.
Two related bugs in the Marmot "new group → add member" path were
preventing the invited user from ever discovering the group, even
though the commit applied cleanly on the creator's side.
1. fetchKeyPackageAndAddMember() searched for the invitee's KeyPackage
on the *creator's* own outbox relays. KeyPackages are published by
the invitee to their own outbox (see publishMarmotKeyPackage), so
this only worked when the two happened to overlap. Union the
invitee's NIP-65 outbox with ours before issuing fetchFirst().
2. addMarmotGroupMember() routed the Welcome gift wrap through
computeRelayListToBroadcast(). Its GiftWrap branch first tries the
recipient's NIP-17 kind:10050 and falls back to
computeRelayListForLinkedUser(), which reaches for
inboxRelays()/relay hints and can legitimately return an empty set
when the recipient has no cached NIP-17/NIP-65 data. client.publish
then silently dropped the welcome, which is why the invitee saw
nothing while the creator's local state looked healthy.
Mirror sendNip04PrivateMessage's delivery strategy instead: publish
to our own outbox (so we retain a copy and the invitee may find it
via a shared relay) unioned with User.dmInboxRelays(), which already
ladders NIP-17 kind:10050 → NIP-65 read relays — the exact set
AccountGiftWrapsEoseManager listens on. A warning log covers the
pathological case where both sides are empty.
Note: spotlessApply/compile could not be run in the sandbox (Android
Gradle plugin unreachable from this environment). Please run
./gradlew spotlessApply locally before merging.
The incoming call push notification was falling back to the raw caller
hex (or short npub) whenever the caller's User object hadn't been
created or their metadata hadn't been loaded yet — which is the common
case when the app is woken up by a push notification with a fresh,
empty in-memory cache.
Switch notifyIncomingCall to use LocalCache.getOrCreateUser, and if the
caller's metadata hasn't been loaded, briefly subscribe via
UserFinderQueryState (same pattern used by wakeUpFor) and wait up to
5 seconds for metadata to arrive before building the notification.
This lets toBestDisplayName() resolve to the user's real name in the
notification popup.
Also tighten NotificationUtils.showIncomingCallNotification to use
getOrCreateUser so that in-app calls fall back to the user's npub
display rather than a raw hex prefix when metadata is unavailable.
Maps the full call flow — NIP-AC event kinds, module layout across
quartz/commons/amethyst, the CallManager state machine, incoming and
outgoing signaling pipelines, group-call invited-vs-watchdog timer
semantics, multi-device "answered elsewhere" handling, and the
load-bearing invariants (stateMutex discipline, self-pubkey guards,
publish-outside-the-lock pattern).
Lives next to CallManager.kt so the next contributor finds it without
an hour of grepping.
https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
Implements draft NIP-82 in the experimental package, following the
NIP-88 polls file structure. Adds three new events:
- SoftwareApplicationEvent (kind 32267): parameterized replaceable event
describing a software application, keyed by reverse-domain `d` tag
- SoftwareReleaseEvent (kind 30063): parameterized replaceable event
grouping assets under a versioned release channel
- SoftwareAssetEvent (kind 3063): regular event binding an installable
artifact to its sha256, mime type, platform and version metadata
SoftwareApplicationEvent (32267) and SoftwareAssetEvent (3063) are
registered in EventFactory. SoftwareReleaseEvent (30063) is intentionally
left unregistered because that kind already maps to NIP-51
ReleaseArtifactSetEvent; callers can instantiate it directly.
When the user is logged in on two devices and receives a call, both
ring. When one picks up, the other should stop ringing immediately —
but crucially, the device that answered must not lose its WebRTC
connection when the silenced device reacts.
The "answered elsewhere" state machine (onCallAnswered at the self-
pubkey branch) was already in place and covered by a unit test, but the
signal it relies on was never actually published: acceptCall wrapped the
CallAnswerEvent only for `groupMembers - signer.pubKey` so in a 1:1 call
it only reached the caller. Sibling devices subscribed to gift wraps
for their own pubkey never saw it and kept ringing until the 60 s local
timeout.
Fixes:
- acceptCall / rejectCall publish an extra gift wrap of the *same*
signed answer/reject event addressed to `signer.pubKey`, so sibling
devices in IncomingCall observe the echo and transition to
Ended(ANSWERED_ELSEWHERE / REJECTED) locally. Neither path publishes
any further signaling, so the device that picked up is never
disturbed.
- onCallRejected: add a self-pubkey early-return mirroring
onCallAnswered. Without it, a self-reject echoing back to a device
already in Connecting/Connected hit the peer-rejection branches,
firing onPeerLeft(signer.pubKey) — which would tell CallController to
dispose its own PeerSession and tear down the live audio.
- onSignalingEvent: record self-answer call-ids in completedCallIds
alongside hangups and rejects, so an out-of-order relay replay
delivering the self-answer before the original offer does not make a
sibling device start ringing for a call it already knows was answered.
Adds multi-device tests covering: the new self-addressed wraps in
acceptCall/rejectCall, the end-to-end "two phones, one answers"
scenario, the Connected / Connecting self-reject echo guards, and the
out-of-order offer-after-self-answer replay case.
https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
Logs from the real device showed that with the previous fixes save and
listGroups now work correctly (878 → 1162 bytes written, file found
after restart), but decrypt was throwing:
InvalidAlgorithmParameterException: Only GCMParameterSpec supported
at AndroidKeyStoreAuthenticatedAESCipherSpi$GCM.initAlgorithmSpecificParameters
AndroidKeyStore's authenticated AES/GCM cipher rejects plain
IvParameterSpec and requires an explicit GCMParameterSpec with the
auth tag length. Switched decrypt() to use GCMParameterSpec(128, iv).
While chasing this I also noticed that MlsGroupManager.restoreAll
was **deleting** the stored group on any exception from load(), on
the assumption that it must be corrupted. This turned a transient
decrypt bug into permanent data loss — the user's group file was
wiped during the first broken restart. Change the catch block to
log and skip instead of delete, so after a fix the next restart
can still recover the data.
https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
Logs from the real device showed that the AndroidMlsGroupStateStore
constructor was throwing NoSuchAlgorithmException for
"AES/GCM/PKCS7Padding" at Cipher.getInstance, which caused
AccountCacheState to silently fall back to InMemoryMlsGroupStateStore
for every account. That's why Marmot groups vanished on every restart.
GCM is an authenticated encryption mode and must not use a block
padding scheme — the correct transformation is AES/GCM/NoPadding.
PKCS7Padding was always wrong; no provider on this device accepts it
for GCM. Switch PADDING to ENCRYPTION_PADDING_NONE.
This class is currently only wired to AndroidMlsGroupStateStore
(AccountSecretsEncryptedStores is defined but not referenced anywhere
in the runtime code path — only in docs/secure-key-storage-migration.md),
so there is no on-disk data that was previously encrypted with the
broken transformation to worry about.
https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm