Five protocol-level fixes and a batch of harness correctness fixes to get
the headless Marmot/Whitenoise interop harness from 1/13 to 5/13 passing
cleanly, with the remaining failures all rooted in wn's per-account
serial event-processor retry backoff (which drops undecryptable
pre-membership commits after several minutes) rather than amy behaviour.
quartz + commons
----------------
* MarmotGroupData: hold CURRENT_VERSION at 2. mdk-core (the Rust MLS
engine used by whitenoise-rs) strict-rejects v3 payloads with
`ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`
— our v3 welcomes and GCE commits never apply, so every cross-client
group flow dies at welcome processing. We still parse v3 happily on
the way in; we just don't emit it until mdk publishes the
forward-compat fix MIP-01 mandates.
* MarmotManager.updateGroupMetadata: MERGE extensions instead of
REPLACING. RFC 9420 §12.1.7 says GCE proposals blow away the old
extension list; callers that pass only [marmot_group_data] dropped
[required_capabilities], which peers then reject. Preserve every slot
except the one we're updating.
* MarmotManager.createGroup: new optional `initialMetadata` parameter
that bakes MarmotGroupData into epoch-0 GroupContext.extensions
directly. Without it, creators had to publish a pre-membership
"bootstrap" commit that no later joiner could decrypt — each such
peer then burned their retry budget on an undecryptable kind:445
before seeing the real state. Threaded through MlsGroup.create /
MlsGroupManager.createGroup.
* MarmotManager.mlsGroupIdHex: new translation helper so any code
juggling the MIP-01 nostr_group_id (what amy indexes on) and the
MLS GroupContext groupId (what mdk indexes on) can cross-reference
them without reaching into MlsGroupManager directly.
amethyst module
---------------
* Account.leaveMarmotGroup: self-demote before SelfRemove per MIP-01,
and promote a surviving member to admin first if the caller is the
sole admin (otherwise we'd throw "admin depletion"). Matches the
cli/GroupMembershipCommands.leave flow.
cli (amy)
---------
* Context.syncIncoming: don't advance `giftWrapSince` on empty polls
(so the first-ever sync doesn't bump the cursor past every
past-timestamped wrap we've ever been sent), subtract 2 days lookback
when filtering (NIP-59 randomWithTwoDays gift wraps can have any
createdAt in the last 48h), and only advance `groupSince` for groups
we actually received events for.
* Context.syncIncoming: after ingest, if any Welcome consumed a
KeyPackage, rotate and publish a fresh one immediately. MIP-00
requires this — a KP can only be welcomed once and leaving the
consumed one on relays just means later senders invite us with a
bundle we no longer have private keys for.
* Context.resolveGroupId: accept either nostr_group_id (amy's primary
key) or the MLS GroupContext groupId (what wn emits) on every verb
that takes a group id. Wired through GroupAdd/Remove/Leave/Metadata
/Read, Message send/list, and all the await* verbs so harness
scripts never have to juggle both forms for a single group.
* GroupCreateCommand: bake initial metadata into epoch 0 (see the
quartz change above). Dropped the now-redundant bootstrap commit
publish and tightened the JSON output to include `mls_group_id`.
* GroupMembershipCommands.leave: self-demote admin before SelfRemove;
promote an heir if we're the only admin, otherwise the GCE would
deplete admins and the leave aborts.
* MarmotIngest.ingestGiftWrap: unwrap the sealed-rumor layer. NIP-59
wrap is gift-wrap(kind:1059) → seal(kind:13) → rumor; the old code
only unwrapped once and then checked `inner.kind == 444`, which is
always false because inner is actually the seal. Unseal once more
before the Welcome check. This single fix is what unsticks every
amy-side Welcome ingestion.
wn harness patches + scripts
----------------------------
* whitenoise-defaults-env.patch: honour $WHITENOISE_DISCOVERY_RELAYS in
`Relay::defaults()` (release builds otherwise bake damus.io / primal
/ nos.lol into every new account's NIP-65 / Inbox / KeyPackage
lists, which breaks publishing and prevents the inbox subscription
plane from ever reaching an operational state in a sandbox).
* setup.sh: sleep 2s after amy's initial kind:30443 publish so
nostr-rs-relay has a chance to fsync before wn's first targeted
discovery query. Without it wn's `keys check` races the relay's
WAL flush and intermittently returns NotFound.
* lib.sh: peel wn's `{"result": …}` wrapper in `jq_group_id`,
`wait_for_invite`, `wait_for_message`, `wait_for_member`. Post-v0.2
wn `--json` output nests everything under `.result` (and
`groups invites[]` nests further under `.group.mls_group_id`) —
these helpers were still pattern-matching on the flat shape, so
they returned empty strings for a perfectly good response.
* tests-{create,manage,extras}.sh: track both group IDs per test
(amy's nostr + wn's MLS), pass each CLI the id it understands, and
bump the post-commit wait timeouts to 90–120s so wn's exponential
retry backoff has time to work through the pre-membership commits
it can't decrypt and get to the ones it can.
https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
Move the "defaults a new Amethyst account gets seeded with" into commons
so the UI and amy agree byte-for-byte on what a fresh account looks like.
commons additions:
- commons/defaults/Constants.kt — relay URL constants (moved from amethyst/)
- commons/defaults/AmethystDefaults.kt — DefaultChannels, DefaultNIP65RelaySet,
DefaultNIP65List, DefaultGlobalRelays,
DefaultDMRelayList, DefaultSearchRelayList,
DefaultIndexerRelayList (moved from
amethyst/model/AccountSettings.kt)
- commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name)
that builds the nine events a new Amethyst account publishes: kind:0, 3,
10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth.
Retrofits:
- AccountSessionManager.createNewAccount now delegates event construction to
the commons helper; it still packages them into AccountSettings for the
Android storage path.
- DefaultSignerPermissions stays in AccountSettings.kt — it uses Android
NIP-55 Permission/CommandType types.
- 19 import updates across amethyst/ to point at the new commons paths.
New CLI verbs:
- amy create [--name NAME]: mint a keypair, write identity.json, seed
relays.json with the Amethyst defaults, publish all nine bootstrap events
to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed.
- amy login KEY [--password X]: accept any identifier form Amethyst's login
screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic
(NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey
(with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys
land with privKeyHex=null; Identity now carries that cleanly.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Five extractions so the Amethyst UI and the new amy CLI share one
implementation for each piece of Marmot/identity behaviour instead of
reimplementing it per platform.
quartz additions:
- MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the
metadata shape every inviter stamps on a fresh group and the outbox-
merge rule every admin commit carries forward.
- KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051,
target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish-
side resolveKeyPackagePublishRelays.
- resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex
/ NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client
infra so NIP-05 resolution is live over HTTP.
commons additions:
- MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by
every removeMember caller.
- MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays):
convenience overload that lifts the base64 decode into the manager.
- MarmotManager.buildTextMessage(..) returning a TextMessageBundle;
optionally persists the outbound inner event (CLI needs persist,
Amethyst relies on LocalCache loopback).
- MarmotIngest.ingest(event): routes kind:1059 / kind:445 through
unwrap -> processWelcome / processGroupEvent -> persist the decrypted
application message. Deliberately platform-agnostic.
Retrofits:
- Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take
a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates
to KeyPackageFetcher.
- AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged.
- AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via
buildTextMessage(persistOwn = false) to avoid double-persisting.
- AccountSessionManager's NIP-05 login branch delegates to
resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get.
- CLI Context exposes nip05Client and requireUserHex; syncIncoming now
calls MarmotManager.ingest; all command sites use KeyPackageFetcher
+ the shared identifier resolver. Npubs util deleted.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Live streams routed through the Live Streams tab go through ShowVideoStreaming
→ ZoomableContentView, not LiveActivity.kt. ZoomableContentView is generic
and was calling VideoView without isLiveStream=true, so live stream segments
ended up in SimpleCache. The cached HLS manifest then went stale (live
manifests roll constantly) and playback stopped after ~30 seconds.
Plumb the flag through the data model: add isLiveStream to MediaUrlVideo,
set it true when ShowVideoStreaming constructs the model, and pass it from
ZoomableContentView into VideoView. URL heuristic (.m3u8) wasn't viable —
that would also bypass cache for on-demand NIP-71 multi-rendition videos
and defeat the original feature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical:
- EmojiPackScreen delete dialog: swap plus icon for Delete
- EmojiGrid: switch emoji AsyncImage from Crop to Fit so non-square
artwork and transparent backgrounds render correctly
- AddEmojiDialog: wrap Address.parse in runCatching; a malformed
user-entered address no longer crashes the app
- BrowseEmojiSetsSubAssembler: use the query scope, not the account
scope, for the feed-freshness collector so the subscription lives
only as long as the query
High:
- Drop descriptive kdoc from AddEmojiDialog and EmojiPackViewModel
- EmojiPackCard takes an optional author; Browse feed always shows
it, MyEmojiList shows it only for foreign packs
- EmojiPackScreen: helper line 'Long-press to remove' above the grid
so the interaction is discoverable
- AddEmojiDialog: private toggle is now a Switch (correct control
for a boolean form value) instead of a FilterChip
Medium:
- EmojiPackState: distinct, accurate error messages on the add/
remove-to-selection paths (was copy-paste)
- OwnedEmojiPacksState: import NameTag/TitleTag rather than using
fully qualified names inline
Private emojis stored in the encrypted .content of kind 30030 EmojiPackEvents
were honest about being private but useless: they never appeared in the `:`
autocomplete or the reaction menu, even for the pack owner. This wires up
async decryption so pack owners actually see their private emojis everywhere
public ones already show.
- quartz: add EmojiPackEvent.publicEmojis() / privateEmojis(signer) /
allEmojis(signer) helpers, mirroring BookmarkListEvent's public/private
accessor split. privateEmojis() returns null for non-author signers, which
preserves the rule that foreign packs cannot expose private entries.
- commons: EmojiPackState.mergePack becomes suspend (mergePackWithPrivate)
and decrypts inside the existing combineTransform hot path; the StateFlow
consumers (account.emoji.myEmojis → EmojiSuggestionState) get private
entries automatically once decryption resolves. The combiner already runs
on Dispatchers.IO so the suspending decrypt does not block the UI.
- amethyst: RenderEmojiPack uses produceState to seed with the public list
immediately and replace with the merged list once allEmojis(signer)
resolves — keeps the reaction-menu gallery responsive.
- AddEmojiDialog explainer + KDoc no longer claim private emojis are
invisible; OwnedEmojiPacksState.toOwnedEmojiPack uses the new accessors.
- Test EmojiPackEventTest covers public/private/all access including the
foreign-signer case.
https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn
Two kinds of noisy warnings appear every time an invitee or a
restarted client catches up with events still sitting on the relays.
Both are actually expected, per-spec behavior — not errors.
(a) Outer ChaCha20-Poly1305 decrypt fails with "current and N
retained epoch key(s)".
A newly-joined member processes the three kind:445s that
advanced the group to their current epoch (add-me-commit plus
any earlier commits). The commits were outer-encrypted with
keys that predate their Welcome, so they never held them — this
is MLS forward secrecy working as intended. No state on the
receiver's side actually needs to update: they're already at
the post-Welcome epoch.
(b) "NO matching KeyPackageBundle for eventId=…" after app restart.
The gift-wrapped Welcome (kind:1059) is still on the relay and
gets redelivered on every subscription refresh. The client's
`processedEventIds` dedup set is in-memory, so after a restart
the Welcome flows all the way to `processWelcome`, which then
fails to find the KeyPackage bundle because it was consumed and
marked during the first processing.
Both of these fire every time a member opens the app, so the logs
currently look like something is broken even when everything is
behaving correctly.
Fix:
- Add `GroupEventResult.UndecryptableOuterLayer(groupId,
retainedEpochCount)`. `MarmotInboundProcessor.processGroupEvent`
and `applyCommit` now call the new `tryDecryptOuterLayer` (which
returns `ByteArray?` instead of throwing) and surface this
variant when every available key fails. `GroupEventHandler.add`
logs it at DEBUG with a one-liner noting "likely from before our
join".
- Add `WelcomeResult.AlreadyJoined(nostrGroupId)`. When
`MarmotInboundProcessor.processWelcome` is called with a
`hintNostrGroupId` we're already a member of, short-circuit
before the KeyPackageBundle lookup and return `AlreadyJoined`.
`processMarmotWelcomeFlow` logs at DEBUG.
- `MarmotManager.processGroupEvent` treats `UndecryptableOuterLayer`
as a no-op for subscription-timestamp updates (same as
Duplicate/Error/CommitPending), so a unreadable past-epoch event
doesn't move the group's `since` forward.
No behavioral changes beyond logging level and the short-circuit of
a Welcome we already processed (which was throwing and returning
Error before). Existing callers that only care about successful
`Joined` / successful `CommitProcessed` / `ApplicationMessage`
branches are unaffected — the two new variants join the
`Duplicate` / `CommitPending` / `Error` quiet side of the sealed
class.
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
The snapshot/restore machinery added in the previous commit
(ca988b7) to implement MDK-style `stageCommit` + `mergeStagedCommit`
introduced a tree-rebuild path (RatchetTree.decodeTls followed by a
fresh SecretTree construction) that, in production, caused a
recursive StackOverflowError in `SecretTree.getNodeSecret` during
encryption after a second add-member cycle. The unit test flow did
not exercise it and couldn't reproduce.
Keep the underlying fix — outer-encrypt outbound kind:445 commits
with the PRE-commit (epoch-N) exporter secret — but deliver it with
far less machinery:
- `CommitResult.preCommitExporterSecret` now carries the
`MLS-Exporter("marmot", "group-event", 32)` value captured inside
`MlsGroup.commit()` BEFORE any state mutation. It's the key
existing members still at epoch N hold, so the outer kind:445
wrap with it is decryptable to them (RFC 9420 §12.4 + MDK parity).
- `MlsGroup.commit()` captures this exporter once at the top of the
function, threads it into the returned `CommitResult`.
- `MarmotManager.addMember / removeMember / updateGroupMetadata`
read `commitResult.preCommitExporterSecret` and pass it to
`MarmotOutboundProcessor.buildCommitEvent(..., exporterKey=...)`.
The eager `group.addMember` / etc. continue to advance the local
epoch immediately — the same behavior as before the previous
commit, which was proven safe under production load.
Removed:
- `MlsGroup.stageCommit` / `mergeStagedCommit` / `stageAddMember` /
`stageRemoveMember` and their snapshot/restore helpers.
- `MlsGroupSnapshot` / `StagedCommit` types.
- `MlsGroupManager.stageAddMember` / `stageRemoveMember` /
`stageRotateSigningKey` / `stageUpdateGroupExtensions` /
`mergeStagedCommit`.
- `tree` back to `val` in `MlsGroup`.
Kept from the previous commit:
- `MarmotOutboundProcessor.buildCommitEvent(... exporterKey: ByteArray?)`
with default falling back to `groupManager.exporterSecret(...)`.
- `MlsGroupManager.processCommit` retains epoch secrets AFTER
successful apply (was a secondary bug polluting the retention
window with duplicates of the current key when a commit failed).
- `pushRetainedEpoch` helper that accepts a pre-captured
RetainedEpochSecrets, used throughout instead of the old
`retainEpochSecrets(nostrGroupId, group)`.
Test updated:
- `testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage` now
uses the eager `addMember` path and asserts that the member still
at the old epoch can outer-decrypt with the pre-commit key shipped
back via `CommitResult`. Also decrypts Alice's self-echo and
sends multiple follow-up messages — this is the scenario that
triggered the production stack overflow before the revert.
- `testAddMemberExposesPreCommitExporterSecret` (renamed) confirms
`CommitResult.preCommitExporterSecret` equals the exporter the
group had immediately before the call, and that the post-commit
exporter differs from it (proves we actually rotated).
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
Per RFC 9420 §12.4 and the MDK reference implementation, a kind:445
Commit MUST be outer-encrypted with the epoch-N exporter secret — the
key existing members still hold — so that they can decrypt, process the
commit, and advance to N+1. Amethyst was instead encrypting with the
epoch-(N+1) key because MlsGroupManager.addMember / removeMember /
updateGroupExtensions applied the commit locally *before* handing the
bytes to MarmotOutboundProcessor.buildCommitEvent, which read the
current (post-commit) exporter via groupManager.exporterSecret.
Observed in a 3-party Amethyst-to-Amethyst test (David creates group,
adds Eden then Fred, sends "Hi"): Eden joined at epoch 1 via Welcome,
then "succeeded" in outer-decrypting the add-Eden kind:445 with her
own post-commit key but failed to apply ("Duplicate encryption key:
leaf 1") because she was already in the tree from the Welcome. Eden
never advanced past epoch 1, so Fred's subsequent join and David's
application message were undecryptable. Fred, joining directly at
epoch 2, saw the Hi message fine — matching the symptom where the
last-added member is the only one who can read new messages.
Fix splits commit creation from commit application on the outbound
path, mirroring OpenMLS / MDK's `create_commit` + `merge_pending_commit`
pair:
- MlsGroup gains stageCommit() / mergeStagedCommit(). stageCommit
captures a full snapshot, runs the existing commit logic (which
mutates in place), captures the post-commit snapshot, then restores
the pre-commit snapshot so the caller observes epoch N until merge.
The returned StagedCommit carries the pre-commit exporter secret
and the framed commit bytes. stageAddMember / stageRemoveMember
are thin wrappers that propose + stage.
- MlsGroupManager exposes stageAddMember, stageRemoveMember,
stageRotateSigningKey, stageUpdateGroupExtensions, and
mergeStagedCommit. The eager addMember/removeMember/commit/etc.
entry points are retained (documented as test-only) so the MLS
unit tests in MlsGroupTest, MlsGroupLifecycleTest, etc. keep
working unchanged.
- MarmotOutboundProcessor.buildCommitEvent takes an optional
exporterKey override; callers pass StagedCommit.preCommitExporterSecret.
- MarmotManager.addMember / removeMember / updateGroupMetadata now
stage → build kind:445 → markEventProcessed → mergeStagedCommit.
- MlsGroupManager.processCommit was also retaining epoch secrets
*before* calling group.processCommit, so a failed commit (such as
the add-me relay echo that the new member can't apply) polluted
the retained window with a duplicate of the current epoch key.
Now retain only on success.
Regression tests:
- testStagedAddMemberUsesPreCommitExporter: asserts stage does not
advance the epoch and that preCommitExporterSecret equals the
pre-stage exporter output.
- testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage: end-to-end
David/Eden/Fred reproduction — creator adds two members in sequence
and publishes an application message; both members must decrypt it.
Fails without the fix, passes with it.
https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
Address the architectural critique from self-review. The leaderboard
math and the shared system card are now platform-neutral primitives in
commons, the on-UI-thread aggregation is gone, and two latent
correctness bugs around subscription lifecycle and zap routing are
fixed.
- Introduce a pure LiveActivityTopZappersAggregator in commons that
takes plain ZapContribution values and returns a sorted, deduped,
anon-bucketed top-N list. Covered by a unit-test suite that is
Compose/Android-independent.
- Introduce LiveStreamTopZappersViewModel in commons/commonMain. It
owns two partitioned contribution maps (stream-scoped and
goal-scoped), mutates them under a Mutex on the IO dispatcher in
response to channel.changesFlow and Note.zaps.stateFlow emissions,
and publishes the aggregator result via a StateFlow<List<TopZapperEntry>>.
UI is now a dumb consumer with no aggregation logic left in the
composable.
- Move StreamSystemCard to commons/commonMain/compose/ so Desktop can
consume it alongside Android.
- Fix attachZapToLiveActivityChannel to run even when a zap receipt was
already consumed by another subscription. Previously a zap first seen
by notifications/profile would never reach the stream's channel
cache; addNote is already idempotent so this is a safe retro-route.
- Fix ChannelFilterAssemblerSubscription to re-invalidate filters when
a live-activity channel's metadata arrives. The 30311 stream event
usually lands after the initial assembler run, so the goal-tag-driven
subscription never fired. Now it re-runs as metadata resolves and the
goal id is discovered.
https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
addMember/removeMember/updateGroupMetadata were publishing the raw TLS
Commit struct inside the kind:445 outer ChaCha20 layer, so receivers
that started with MlsMessage.decodeTls read the first two bytes of the
Commit as the MLS protocol version and aborted with "Unsupported MLS
version: <garbage>" (e.g. 16704 = 0x4140).
Wrap commits produced by MlsGroup.commit in a PublicMessage carrying
the sender leaf index and confirmation_tag, then in an MlsMessage
envelope, and expose those bytes via CommitResult.framedCommitBytes so
MarmotManager uses them instead of the raw commit bytes. The internal
CommitResult.commitBytes field stays raw so MlsGroup.processCommit and
existing tests that exercise it directly keep working.
Also mark the committer's own published kind:445 as already processed
in MarmotInboundProcessor so that the relay echo of our own commit is
treated as a duplicate rather than re-applied on top of the already-
advanced local epoch.
https://claude.ai/code/session_01NR6JgYRimVb142T2nkChuh
Previously ChatroomListKnownFeedFilter dropped any group whose
newestMessage was null, so groups the user just created and groups
received via Welcome events without any activity yet never appeared
in the Messages list.
Emit a stable placeholder Note (carrying the chatroom as a gatherer)
per empty group, route it through the existing Marmot row path, and
invalidate dmKnown on MarmotGroupList.groupListChanges so newly added
or promoted groups rebuild the feed.
Replaces the comma-separated relay text on the Marmot Group Info
screen with a FlowRow of 55dp relay avatars that open RelayInfo on
tap and copy the URL on long-press, matching the relay-icon pattern
used elsewhere in the app.
To surface whether each configured relay is actually carrying traffic
for the group, MarmotGroupChatroom now tracks the most recent
kind:445 event timestamp observed from each delivering relay and the
info screen renders a green dot when a relay has delivered a group
event within the last 7 days (gray otherwise).
The "h" tag is optional in MIP-02 Welcome events — senders like
whitenoise-rs omit it. Instead of failing when the h-tag is absent,
derive nostrGroupId from the NostrGroupData extension embedded in the
Welcome's GroupContext (the authoritative MLS source). An h-tag hint,
if present, is still validated against the MLS-derived value.
Expose the member list as MutableStateFlow<List<GroupMemberInfo>> on
MarmotGroupChatroom, populated by MarmotManager.syncMetadataTo alongside
the existing memberCount. MarmotGroupInfoScreen and RemoveMemberScreen
now observe it via collectAsStateWithLifecycle, so both remote commits
(already routed through syncMetadataTo in DecryptAndIndexProcessor) and
local mutations refresh the UI without manual re-queries.
Account.addMarmotGroupMember and removeMarmotGroupMember now call
syncMetadataTo right after the MLS state is mutated, mirroring the
pattern already used by updateMarmotGroupMetadata, so the local change
is visible immediately without waiting for the relay round-trip.
The reference port recomputed the inverse DCT cosine tables once per output
pixel — for a 32x24 decode that's ~43k redundant cos() calls. This change
lifts the tables out of the inner loop and caches them across decodes
keyed by (size, componentCount) so subsequent placeholders at the same
target size skip cosine evaluation entirely.
Additional wins:
- AC coefficients unpack into pre-sized DoubleArrays (no ArrayList<Double>
boxing, no post-hoc copy)
- truncated hashes are rejected up-front via a single length check instead
of mid-stream
- LPQA -> sRGB uses an inline branch clamp instead of a
min/max/round/coerceIn chain
Tests: 12 unit tests cover determinism across repeated decodes, cache
clearing, alpha preservation, aspect-ratio preservation both directions,
average-color drift bounds, truncated input rejection, and round-tripping
base64 vs. raw bytes. All green.
Bench: new ThumbHashBenchmark exercises opaque decode, alpha decode,
warm- and cold-cache decode, aspect-ratio probe, and the full
decodeKeepAspectRatio pipeline so the perf delta is visible on device.
Adds a parallel ThumbHash placeholder everywhere BlurHash is already used.
Remote events now carry both hashes; renderers prefer thumbhash when
available and fall back to blurhash. The MIP-04 `thumbhash` imeta field
that was previously reserved-but-unused is now wired end-to-end.
- New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep)
- New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68,
71, 94, 95, 99, the experimental profile gallery, and MIP-04
- RichTextParser + MediaContentModels carry a thumbhash field alongside
blurhash so every downstream composable can pick its preferred placeholder
- New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a
small placeholderModel(thumbhash, blurhash) helper centralising the
"prefer thumbhash, fall back to blurhash" rule
- Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the
calculator decodes the bitmap / video thumbnail once and runs both
encoders on the same pixels to keep upload cost flat
- DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface
both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM,
Classifieds, picture, video, and long-form upload paths
- Round-trip unit test for the ThumbHash port
Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs
(detected by .pdf extension, NIP-92 imeta m tag, or application/pdf
Content-Type) render a card showing the first page, filename, and page
count. Tapping opens a full-screen HorizontalPager over every page
rendered on demand with PdfRenderer. Long-press surfaces the existing
share menu. Uses only the built-in Android PdfRenderer; no new
dependencies. Desktop continues to fall back to a clickable link.
Adds a dismissible top-of-screen banner to the Pinned Notes, Bookmarks,
Old Bookmarks, and Bookmark Set (detail) screens that appears when one
or more listed items have been deleted by their author (kind-5 deletion,
pubkey-matched via DeletionIndex). Tapping "Remove from list" rewrites
the underlying list event (NIP-51 kind 10001/10003/30001/30003) with
the deleted entries stripped out and broadcasts it once.
The scan is scoped to each screen's loaded list (typically <20 items)
and runs only while composed, so it avoids the performance hit of
hooking Account.deletedEventBundles, which fires for every kind-5
deletion in the firehose. Public and private (NIP-44 encrypted)
bookmarks are both cleaned in a single resign() call per list.
Root cause of "ringing survives reject on old Android":
1. startRinging() was NOT idempotent. When CallManager re-emitted
IncomingCall state (e.g., when another group member rejected while
still ringing), the state collector called startRinging() again.
startRingtone() OVERWROTE the ringtone field reference without
calling stop() on the old one. Old Ringtone instances kept
playing with no reachable reference — stopRingtone() on reject
only stopped the latest one.
2. Ringtone.isLooping was only set on API 28+. On older Android the
Ringtone class had documented reliability problems with stop().
MediaPlayer has reliable stop/release on all API levels and
supports looping universally.
Fixes:
- Switch from android.media.Ringtone to android.media.MediaPlayer.
- Make startRinging() idempotent — always call stopRinging() first
so any existing player/vibrator is torn down before a new one
starts.
- Add aggressive tracing logs throughout the reject path:
* CallAudioManager: instance id + thread on every start/stop,
MediaPlayer error listener, before-and-after player hashes.
* CallSession: log every state collector tick, log close() entry.
* CallManager: log rejectCall() entry/exit + transitionToEnded.
* CallNotificationReceiver: log every action with state transitions.
With these logs, if ringing still survives reject we can trace
exactly which primitive is failing.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Three fixes from audit:
1. PiP swipe-away regression (Bug 5): Restored hangup signaling in
CallActivity.onDestroy via detached CoroutineScope. This is the
PRIMARY signaling path for PiP dismiss, back press, and finish().
CallForegroundService.onTaskRemoved is the BACKUP for task swipe
from Recents. Both paths are idempotent — double-publishing is safe.
2. SharedFlow event drops (Bug 1): Increased sessionEvents buffer from
16 to 256 to handle worst-case ICE candidate bursts. Added
emitSessionEvent() helper that logs drops as errors. Increased
renegotiationEvents buffer from 8 to 32. tryEmit is kept (not
emit) to avoid suspending while holding stateMutex, which would
block all signaling.
3. runBlocking deadlock risk (Bug 2): CallSessionBridge.clear() now
uses CallManager.reset() (non-blocking, no mutex) instead of
runBlocking { hangup() }. The bridge teardown is for local state
cleanup during account switch — hangup signaling is the Activity's
and Service's responsibility.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Convert all 5 mutable `var` callback fields on CallManager
(onAnswerReceived, onIceCandidateReceived, onNewPeerInGroupCall,
onMidCallOfferReceived, onPeerLeft) into a single
`sessionEvents: SharedFlow<CallSessionEvent>`.
Benefits:
- Eliminates the stale-callback window between Activity destroy and
recreate. No more nulling callbacks in onDestroy — the `closed`
flag on CallSession is sufficient.
- No mutable state shared between Activity and background code.
- CallActivity no longer wires or tears down callbacks — CallSession
subscribes to the flow in its init block.
- Type-safe sealed interface (CallSessionEvent) replaces 5 separate
lambda types.
The renegotiationEvents SharedFlow remains separate because
renegotiation has its own glare-resolution logic in CallSession.
Updated all 11 test sites in CallManagerTest to collect from
sessionEvents instead of setting callback vars.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Three additional hardening fixes:
1. WebRTC callback guards: Every callback passed to WebRtcCallSession
(onIceCandidate, onPeerConnected, onRemoteVideoTrack, onDisconnected,
onError, onRenegotiationNeeded, onIceRestartOffer) now checks `closed`
before touching any session resource. Prevents native crashes from
libwebrtc callbacks firing after close() has disposed PeerConnections.
2. Connecting-state watchdog: New 30-second timer armed when entering
Connecting state, disarmed on Connected/Ended/reset. If ICE
negotiation hangs (broken TURN, restrictive NAT), the call ends
with TIMEOUT instead of leaving the user on a "Connecting..." screen
forever.
3. Account-switch safety: CallSessionBridge.clear() now calls
callManager.hangup() before nulling references. Prevents a stale
CallSession from invoking signing/publishing lambdas on a disposed
Account after logout or account switch.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Root cause: WebRTC call ringing continued forever after cancellation
because ringing, WebRTC, and notifications were owned by CallController
which lived in viewModelScope (account lifetime), not by CallActivity.
Cleanup depended on a state collector observing Ended in time, which
raced, dropped, and went stale.
Architecture after fix:
- CallManager (background, AccountViewModel scope): state machine only.
No audio, no WebRTC, no notifications. Exposes state as StateFlow,
renegotiation events as SharedFlow, and a 65-second ringing watchdog
as a fail-safe.
- CallSession (Activity-scoped, replaces CallController): owns all call
resources. Created in onCreate, destroyed via close() in onDestroy.
Implements AutoCloseable. No cleanedUp guard flag needed.
- CallSessionBridge: slimmed down — holds only callManager +
accountViewModel for cross-Activity reference sharing. No
callController field.
Key changes:
- CallController renamed to CallSession, moved to ui/call/session/.
- CallSession.close() replaces cleanup() — single-shot, no guard flags.
- CallActivity creates and owns CallSession directly.
- CallManager.onRenegotiationOfferReceived callback replaced with
renegotiationEvents SharedFlow (buffered, survives Activity restarts).
- CallManager gains ringing watchdog (65s) that forces Ended(TIMEOUT)
if state stays in IncomingCall/Offering past the deadline.
- CallNotifier extracted from NotificationUtils into dedicated class.
- AccountViewModel.initCallController deleted; callManager wired to
newNotesPreProcessor eagerly in init block.
- ChatroomScreen uses CallActivity.launchForOutgoingCall() with intent
extras instead of directly calling callController.initiateGroupCall().
- AndroidManifest adds navigation|fontScale|density to configChanges
so no config change recreates CallActivity mid-call.
Invariants:
1. CallActivity.onDestroy → session.close() → all resources released.
2. Ringing cannot outlive any code path: Activity close, state
collector, and 65s watchdog provide three independent stop paths.
3. Config changes cannot kill the call (manifest prevents recreation).
4. No global mutable singleton for CallController.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
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
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.
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.
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
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
User-reported issue: creating a Marmot group works in a single session,
but the group is gone after app restart. The store code path survives
the persistGroup save on creation, but something between save and
restore silently loses the state.
Changes:
- KeyStoreEncryption: pass "AndroidKeyStore" explicitly to
KeyGenerator.getInstance so generator.init(KeyGenParameterSpec) is
guaranteed to land on the AndroidKeyStore provider (the default
provider selection is not guaranteed to choose it). Also wrap
encrypt/decrypt in try/catch so the real exception is logged
instead of bubbling up as a generic failure.
- AndroidMlsGroupStateStore: log rootDir at construction, and log
every save/load/listGroups/delete with file paths, byte counts,
and whether the target file actually exists after writing. Any
thrown exception is now logged with full stack trace before
rethrowing.
- MlsGroupManager: log create / persist / restoreAll with group ids,
byte counts, and in-memory group count so we can see exactly which
step drops state. The existing "corrupted state, delete" branch now
logs at ERROR with a clearer marker so it's easy to spot in the logs.
- MarmotManager + AccountCacheState: log which MLS store implementation
is chosen per account (Android vs InMemory fallback) and the root
filesDir path, plus restoreAll() begin/end with restored group ids.
These logs should make it obvious whether:
1. persistence is silently falling back to InMemoryMlsGroupStateStore,
2. save is actually writing the file to disk,
3. listGroups is finding the directory on restart,
4. decrypt is throwing and the restoreAll catch block is wiping
the "corrupted" state.
https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
Scenario: Alice calls {bob, carol} as a group. Bob answers, Bob and
Alice are talking, Carol never joins. Before Carol's 30 s watchdog
fires, Alice hangs up. Bob's state had peerPubKeys={alice} and
pendingPeerPubKeys={carol}. The old onPeerHangup handler only ended
the call when BOTH sets became empty, so Bob stayed in Connected
alone staring at a black screen until Carol's watchdog finally
dropped her — and even then the state machine didn't terminate
because "no peers, no pending" wasn't true at the exact same step.
Change the termination rule in onPeerHangup (both Connected and
Connecting states) to end the call as soon as connectedRemaining
becomes empty, regardless of how many pending peers are still
tracked. Without a single connected peer the call has no one to
talk to and can't meaningfully continue.
Also publish a CallHangup to any peers in the pending set that WE
personally invited (peersInvitedByUs ∩ pendingPeerPubKeys) so their
devices stop ringing. Peers we did not invite (e.g. group members
the caller originally included) are the caller's responsibility —
the caller's own group hangup already reaches them — so we leave
them alone.
Adds three regression tests covering:
- Connected state: caller hangs up while one pending member remains
- Connecting state: same, before onPeerConnected fires
- Callee had invited someone mid-call: call ends AND Bob notifies
his invitee to stop ringing