Adds two inner-event verbs on top of the existing kind:9 text send path:
amy marmot message react GID TARGET_EVENT_ID EMOJI
amy marmot message delete GID TARGET_EVENT_ID...
Both build an unsigned rumor per MIP-03 (RumorAssembler) and wrap it in
the usual kind:445 outer via MarmotOutboundProcessor — the same shape as
text messages, just a different inner kind. The target event is looked
up in the account's decrypted inner-message log (persisted by the
inbound pipeline) so the NIP-25 / NIP-09 templates can populate the
target's pubkey and kind tags without making the caller re-derive them.
Test 09 previously noted that react round-trip verification was blocked
on a missing CLI verb. Expanded it to react via amy and confirm B sees
the kind:7 surface in its messages stream.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
Mirrors the Android Danger-Zone action: wipes every MLS group, retained
epoch secret, persisted KeyPackage bundle, relay subscription, and the
run-state since-cursors. Does not broadcast any SelfRemove/leave
commits, because the reset path is for recovering from corrupted or
unrecoverable local state where graceful teardown may be impossible.
Requires an explicit --yes to execute. Without --yes it emits a dry-run
JSON listing the groups that would be wiped and exits 2, so scripts
that forget the flag fail loudly instead of nuking state.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
Gives users a last-resort "nuclear" option when Marmot local state is
corrupted or otherwise unrecoverable — wipes every MLS group, retained
epoch secret, persisted KeyPackage bundle, subscription and in-memory
chatroom for the current account. Does not broadcast leave/SelfRemove
commits, since a graceful teardown may be impossible in exactly the
scenarios where users reach for this option. The KeyPackage is
republished lazily on the next sync so the account stays reachable.
Adds clearAllState() helpers on MlsGroupManager and
KeyPackageRotationManager, a resetAllState() orchestrator on
MarmotManager, an Account.resetMarmotState() entry point, and a
destructive row + confirm dialog in the AllSettingsScreen Danger Zone
that mirrors the existing Request-to-Vanish pattern.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
Kotlin rejects the annotation being applied twice on the same function —
broke compileFdroidDebugKotlin and compilePlayDebugKotlin. Keeping the
one above @Composable, removing the duplicate below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills in 60 newly-added English strings (always-on notifications, audio
rooms, battery optimization banner, bottom bar settings, drawer sections,
feed titles, search filters/scopes/sort modes, video player button
settings) across all four target locales, plus a handful of pre-existing
gaps (emoji_pack_count, interest_set_hashtag_count, badge_name_label,
new_community_relays_section) that were missing from individual locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trims ~300 lines of screen output from an all-pass run without losing
any post-mortem detail — all removed lines still land in $LOG_FILE:
- preflight: move tool-path listings to $LOG_FILE only
- configure_relays: collapse 30+ per-relay "add ok" info lines into a
single "X ok, Y already-present, Z failed" summary
- configure_relays: drop verbose "B relay list after configure" dump
(file only); compress kind:30443 / kind:10050+1059+445 sanity logs
to single summary lines
- discover_a_relays: replace 10-line per-type relay list with a
nip65/inbox/kp count summary; full lists in $LOG_FILE
- main: drop unconditional "Post-configure baseline diagnostics"
(dump_daemon_diagnostics already fires on invite-timeout)
- test_02: move pre-invite dump_daemon_diagnostics and post-send
dump_outbound_diagnostics into the failure branches only
- test_04: drop unconditional pre-invite daemon dump
- test_01: send raw relay-list blob to $LOG_FILE instead of stderr
Failure paths still dump full daemon diagnostics, so debugging a broken
relay set loses nothing.
https://claude.ai/code/session_011HLbt9QMwBMSxovNRkv4GB
Old test_13 asked the operator to restart Amethyst or toggle KP relays
to trigger rotation, but neither does in current Amethyst:
- Restart: ensureMarmotKeyPackagePublished early-returns when a bundle
already exists on disk.
- Toggle KP relays: saveKeyPackageRelayList republishes the existing
kind:30443 to the new relay set (same event_id).
Rotation only fires when A's own KeyPackage is *consumed* by an
incoming Welcome. Switch the test to have B create a one-off group
inviting A, which consumes A's KP and triggers the rotation path in
DecryptAndIndexProcessor -> publishMarmotKeyPackages. wn then sees a
new event_id for the same d-tag slot.
https://claude.ai/code/session_011HLbt9QMwBMSxovNRkv4GB
When the local user leaves a Marmot MLS group, MarmotManager already
wipes MLS state, the relay subscription and the persisted message log,
but the chatroom (and its decrypted inner messages) lingered in
marmotGroupList until the UI happened to call removeGroup. The CLI
leave path skipped that step entirely, and removeGroup itself only
dropped the chatroom from rooms — it left noteToGroupIndex entries and
the chatroom's own messages set in place, so notes stayed strongly
referenced and kept appearing in the Notification feed.
Move the in-memory cleanup into Account.leaveMarmotGroup, and have
MarmotGroupList.removeGroup also clear the chatroom's messages and the
note→group index. LocalCache holds notes weakly, so cutting the strong
refs is enough — GC reclaims them and the existing acceptableEvent
filter (which keys off marmotGroupList.rooms) hides anything still
in-flight.
`MarmotInboundProcessor.processGroupEvent` added every event id to its
dedup set regardless of result, including `UndecryptableOuterLayer`. The
handler buffers those events for a post-commit retry, but the replay hit
the Duplicate early-return and skipped MLS decryption — so future-epoch
messages and group-metadata commits that arrived before their epoch's
commit were silently dropped until an app restart re-fetched them.
This is interop test 12 (offline catch-up): messages 6-8 and the rename
commit only appeared after restarting Amethyst.
Skip the dedup write on `UndecryptableOuterLayer` so retries actually
re-attempt decrypt once the epoch advances. Memory is still bounded by
the handler's per-group pending buffer (64 events, FIFO-evicted).
Dead videoGroup parameter in inner RenderTopButtons overload. Confirmed via git show origin/main — the branch introduced it, and it's only passed through but never read inside the body (the outer overload uses
videoGroup separately for VideoQualityPopup). Removed from signature and both call sites (preview + outer wrapper).
RenderTopButtons runs for every visible video tile, so recomputing the
top-bar / overflow action lists on every recomposition added avoidable
per-frame filter+map allocations. Memoize on the inputs that actually
change availability.
Also drop the outer Box around AnimatedOverflowMenuButton — the inner
OverflowMenuButton already provides its own sized anchor Box, so the
wrapper added no layout value.
Mirror the existing reaction row settings pattern. Each of the six
configurable video player buttons (Fullscreen, Mute, Quality, Share,
Download, PictureInPicture) can be placed either in the top bar or in
the overflow menu, with drag-to-reorder within its chosen location.
- Data model: VideoPlayerAction, VideoButtonLocation, VideoPlayerButtonItem
added to AccountSyncedSettingsInternal so the config syncs via NIP-78.
- StateFlow wrapper AccountVideoPlayerPreferences plumbed through
AccountSettings, Account, and AccountViewModel.
- New settings screen at Route.VideoPlayerSettings mirroring the reaction
row screen (drag-to-reorder list with per-row FilterChip location).
- RenderTopButtons partitions configured buttons into top-bar vs
overflow lists; overflow icon is hidden entirely when empty. Existing
availability rules are preserved (Fullscreen needs zoom handler,
Quality needs HLS with multiple renditions, Download hidden on live
streams, PictureInPicture hidden without device support).
- Volume is now observed via Player.Listener.onVolumeChanged so the
overflow Mute row stays in sync with the current state.
- VideoQualityPopup extracted so the quality chooser can open from the
overflow menu too.
The notification filter accepted any note whose `inGatherers` contained
a MarmotGroupChatroom, regardless of which account's list that chatroom
belonged to. Since notes live in the global LocalCache and accumulate
gatherer references across account switches (and are not cleaned up on
leave), old messages stayed in Notifications after switching accounts
or leaving a group.
Mirror the NIP-17 DM guarantee: only accept a Marmot message when the
gatherer is the same instance currently held by the active account's
`marmotGroupList.rooms[nostrGroupId]`.
Replaces the two-row, mixed-control filter UI under the search bar with a
single segmented row for scope (All/People/Notes) and a Tune icon button
that opens a ModalBottomSheet containing Source, Follows-only, and Sort
controls. A primary-color dot on the icon indicates non-default filter
state.
Also renames SearchSortOrder.DEFAULT_EVENT/DEFAULT_PEOPLE to
EVENT_DEFAULT/PEOPLE_DEFAULT for consistency with EVENT_OPTIONS/
PEOPLE_OPTIONS, and routes the sheet's reset button through these
canonical constants so the UI default cannot drift from the ViewModel.
Android search previously dumped users, notes, hashtags, relays, and 3 channel
types into a single untoggled list and silently relied on NIP-50 relay search.
This adds a second-row control strip so users can pick what they're looking for
and how results are sourced/ranked.
Commons:
- New SearchScope { ALL, PEOPLE, NOTES } and SearchSource { LOCAL, RELAYS }
- SearchSortOrder: add POPULAR; EVENT_OPTIONS now [RELEVANCE, NEWEST, POPULAR]
Android SearchBarViewModel:
- scope / source / followsOnly / sortOrder StateFlows
- searchResultsUsers + searchResultsNotes respect scope, follows, and sort
- POPULAR sorts notes by Note.zapsAmount (descending)
- Follows-only filters authors (includes replies since filter is by author)
- directEventResolver: bech32 nevent/note/naddr -> Route.Note (auto-navigate)
- updateDataSource skips relay emit when source == LOCAL
Android SearchScreen:
- Second row: [All | People | Notes] SegmentedButton + Sort dropdown
- Third row: [Local | Relays] SegmentedButton + Follows-only chip
- bech32 hit triggers nav.nav(route) and clears the search
Desktop parity deferred - Desktop already ships an advanced panel that covers
most of this (kinds/authors/dates/hashtags/language/exclude terms + sort +
bech32 direct lookup). The new enums live in commons so Desktop can adopt.
Build note: gradle 9.3.1 can't be downloaded in this environment, so Android
and Desktop compilation were not verified here.
R8 in playBenchmark (minifyEnabled) stripped the no-arg <init> from
WorkDatabase_Impl because -keepnames preserves names but not members.
Room instantiates generated *_Impl subclasses reflectively via
Class.getDeclaredConstructor(), so startup crashed with
NoSuchMethodException in androidx.startup.InitializationProvider.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExoPlayer's URI-based content-type inference only fires for http(s)
schemes + known extensions, so HLS playback fails in two cases Amethyst
ships regularly:
1. NIP-71 video events with BUD-10 blossom URIs. Hash-based URIs carry
no extension and the imeta's application/vnd.apple.mpegurl never
reaches the MediaItem — MediaItemCache dropped it.
2. Bare BUD-10 URIs pasted into kind 1 posts, which have no imeta at
all. Even though the URI contains `.m3u8` in its path, ExoPlayer's
inference skips custom schemes (`blossom:`).
Forward a normalized mimeType onto MediaItem.Builder.setMimeType so
DefaultMediaSourceFactory routes to HlsMediaSource regardless of scheme
or extension:
- Normalize IANA Apple HLS variants (application/vnd.apple.mpegurl,
application/x-mpegurl, audio/x-mpegurl, audio/mpegurl) to
MimeTypes.APPLICATION_M3U8.
- Fall back to `.m3u8` URI detection when no imeta mimeType is
present. Matches the existing isLiveStreaming(url) heuristic.
Adds diagnostic logs at MediaItemCache.compute and
CustomMediaSourceFactory.createMediaSource so the routing decision is
observable in logcat, matching the diagnostic-logs pattern already used
elsewhere in playback.
- Persist relay list events (kinds 10050/10007/10006) as JSON to
java.util.prefs.Preferences with per-account key isolation
- Load persisted relay configs on startup before bootstrap subscription
- Validate loaded events (kind + pubkey check), 8KB guard on writes
- Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays
- Fix FeedScreen relay count: shows feed relay count, not all connected
- Per-screen relay picker dialogs: Dns icon on Feed and Search screens
opens AlertDialog wrapping existing editors (Nip65RelayEditor,
SearchRelayEditor) — no new composable files
- Fix created_at dedup: use >= for replaceable event semantics
- Fix setters: use TimeUtils.now() not Long.MAX_VALUE
- Add consumePublishedEvent() for local immediate update after publish
- Remove stale "not loaded" warnings from Search/Blocked editors
- Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any>
- Fix picker LaunchedEffect(Unit) to not overwrite user edits
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test 12 (offline catch-up) was almost passing: the retained-epoch
fallback (c88923a3) brought through the 5 epoch-1 application messages
the user sent before adding C, but 3 epoch-2 messages + the rename
commit still went missing. Only on app restart did they appear — the
relay re-delivered and the replayed arrival order happened to put the
epoch-2-advancing commit before the epoch-2 events.
Root cause: kind:445 events arrive from the relay in subscription
order, not epoch order. When B drives an offline-catch-up sequence
like
msg-1..5 (epoch 1) → add C (epoch 1→2) → msg-6..8 (epoch 2) →
rename (epoch 2→3)
the receiver can easily see {msg-6, msg-7, msg-8, rename, add-C,
msg-1..5}. Everything from the rename onward arrives before the
add-C commit, so the outer ChaCha20-Poly1305 layer fails with
UndecryptableOuterLayer — we don't have the epoch-2 exporter yet.
Quartz correctly reports the failure, but the receiver just logged
"likely from before our join" and dropped the event on the floor.
Nothing retried when the add-C commit eventually landed and the
epoch caught up.
Fix it at the GroupEventHandler level, where we can both see the
original GroupEvent and hook into every CommitProcessed:
- Keep a bounded per-group ArrayDeque<GroupEvent> of events that
failed with UndecryptableOuterLayer. FIFO-evict at 64 entries so
a genuinely pre-join stream can't pin unbounded memory.
- On every CommitProcessed for a group, drain that group's queue
and re-run each event through the normal add() path. A successful
retry may itself emit another CommitProcessed and trigger recursive
draining — that's fine, epochs are strictly monotonic so recursion
depth is bounded by the queue size.
- Concurrent-safe: all queue mutation is guarded by a Mutex.
Ambient discriminator: we can't tell "epoch too old, from before our
join" from "epoch too new, commit hasn't arrived yet" just from
UndecryptableOuterLayer — both report it. Buffering both is fine:
truly-pre-join events stay buffered until FIFO eviction pushes them
out (no correctness problem, just a few dozen bytes of memory for a
bounded time).
MIP-03 inner events inside a kind:445 GroupEvent are unsigned (sig is
empty or placeholder), so LocalCache's `justVerify()` fails and
`consume(ReactionEvent)` skips the branch that attaches the reaction to
its target note via `addReaction()`. The same applies to kind:5
deletions. Side-channel inner events from other Marmot clients
(WhiteNoise in particular) were therefore silently dropped by
LocalCache, so an inbound emoji reaction never appeared on the target
chat bubble.
NIP-17 has the same "unsigned inner rumor" shape and already hands the
inner event to `justConsume(..., wasVerified = true)`. Align Marmot with
that contract at the two inbound call sites:
- `GroupEventHandler.add` (fresh kind:445 from relay)
- `Account` restore-from-disk loop (persisted plaintext at app start)
Authenticity is already guaranteed by the MLS layer:
`MarmotInboundProcessor.processPrivateMessage` rejects any inner event
whose `pubkey` doesn't match the MLS sender credential identity before
ever emitting an `ApplicationMessage`.
https://claude.ai/code/session_01KhVtLJAEkNCwpRWUUTqDFM
The `tryDecryptWithRetainedEpoch` fallback in `MlsGroupManager.decrypt`
exists precisely to cover the interop harness's Test 12 scenario —
application messages encrypted under epoch N arriving at a receiver
that has already processed a commit advancing to N+1. The primary
path throws "Message epoch X doesn't match current epoch Y", then the
manager iterates the retained epoch window and tries each.
Two bugs kept the fallback from ever succeeding:
1. sender-data ciphertext sample length. The primary decrypt path uses
`MlsCryptoProvider.HASH_OUTPUT_LENGTH` (32 bytes, = KDF.Nh for
HKDF-SHA256, per RFC 9420 §6.3.2); the retained path was using
`AEAD_KEY_LENGTH` (16). The comment on the primary path — "Using
AEAD.Nk here made sender-data decryption fail against every
spec-compliant sender" — was the same fix just never propagated
to this branch. sender-data AEAD always failed, the try/catch
swallowed it, and the fallback returned null.
2. content plaintext was returned raw. AEAD output is a
PrivateMessageContent struct (§6.3.1):
`applicationData<V> || signature<V> || padding`.
The primary decrypt path parses this with `pmcReader.readOpaqueVarInt()`
to extract applicationData; the retained path returned the whole
struct. Callers saw a length-prefixed blob with signature + zero
padding glued on — obvious in a raw diff, invisible in a pass/fail
test that only checked "decrypted is not null".
Fix both in `tryDecryptWithRetainedEpoch`. Existing quartz↔quartz
tests still pass because they don't exercise out-of-order decrypt —
this branch was effectively dead code before.
Add testDecryptRetainedEpoch_ApplicationMessageAfterCommit as a
regression: Bob encrypts at epoch N, Alice advances to N+1 via
add-member, Bob's epoch-N message then arrives at Alice — must round-
trip through the retained-epoch fallback and match the original
plaintext exactly.
Adds a star toggle next to each member in MarmotGroupInfoScreen that
admins can use to promote a non-admin to admin or demote a fellow
admin. Both paths show a confirmation dialog and publish a
GroupContextExtensions commit that rewrites admin_pubkeys. The toggle
is hidden for non-admin viewers and for the signer's own row (self
demotion must go through the leave flow). The demote button is also
hidden when the member is the only remaining admin so we never hit the
MIP-03 admin-depletion guard in MlsGroup at commit time.
Account.grantMarmotGroupAdmin / revokeMarmotGroupAdmin reuse
updateMarmotGroupMetadata so the new admin list ships alongside the
signer's outbox relays, keeping the canonical relay set consistent
with other metadata commits.
Test 13 (KeyPackage rotation) fails with "no new KP event_id observed"
— the interop log shows three `needsKeyPackageRotation=true` triggers
(once per group join), each of which invokes
`account.publishMarmotKeyPackages()` from `processMarmotWelcomeFlow`,
but no subsequent `kind:30443` publish ever appears in the logcat.
`publishMarmotKeyPackages` was completely silent: no-op guards, the
needsRotation check, the rotated-events count, and the per-event
publish all ran without a log line. We can't tell whether the rotation
produces events that never reach a relay, produces zero events,
short-circuits on needsRotation=false, or is never called at all.
Mirror the addMarmotGroupMember log pattern: entry guards (manager
null / not writeable), the needsRotation + relay count at decision
time, the rotateConsumedKeyPackages produce-count, and a per-event
publish line with id + target relay list. Next rerun of Test 13 will
tell us which branch is silently dropping the rotation.
Interop Test 06 (wn removes a member from a group Amethyst is in) was
failing with:
GroupEventHandler.add: ERROR Failed to apply commit:
UpdatePath node count (1) doesn't match direct path length (2)
Repro scenario: 3-member group [B=leaf 0, C=leaf 1, A=leaf 2]; B (admin,
committer) removes C. After the Remove proposal applies, leaf 1 is
blank but leaf 2 (A) is still occupied — so leaf_count stays at 3 per
RFC 9420 §7.8 and the sender's direct path has length 2 ([node 1,
root]).
Per RFC 9420 §4.1.2 and §7.9 the **filtered direct path** drops any
parent node whose child on the copath has an empty resolution:
encrypting to such a parent is equivalent to encrypting to its only
non-blank child, which is already on the direct path. In our scenario
the parent at level 1 (parent of leaves 0, 1) has a blank leaf 1 on
the copath — empty resolution — so it's filtered out. Filtered
direct path length is 1, and that's what openmls / whitenoise-rs emit
in `UpdatePath.nodes<V>`.
Quartz was always using the unfiltered direct path on both sides
(send + receive + parent-hash chain + path-secret decrypt). That
worked for quartz↔quartz (both sides agreed), but broke the moment a
spec-correct sender (wn) sent a filtered UpdatePath into a quartz
receiver. Parent-hash chain is further affected because §7.9.2 walks
the filtered path — even if the size check passed, the hashes would
not match.
Fix it throughout:
- RatchetTree.filteredDirectPath: new helper returning parallel
(filteredDirectPath, filteredCopath) with the empty-resolution
entries dropped. Uses the existing `resolution()` helper, which
already handles unmerged_leaves per §4.1.2.
- RatchetTree.applyUpdatePath: size-check + target the FILTERED path.
- MlsGroup.commit (sender): emit one staged path node per filtered
level; encrypt path secrets using the filtered copath; patch parent
hashes into the filtered positions only.
- MlsGroup.processCommitInner (receiver): the UpdatePath node index
aligns to the filtered direct path, so the common-ancestor lookup
must find the filtered index to pick the right ciphertext + copath
resolution. Use the unfiltered index only for counting KDF steps
to the root (path_secret chain advances one step per unfiltered
level regardless of filtering — the chain is continuous; filtering
only decides which levels emit a UpdatePathNode, not how many KDF
steps separate them).
- MlsGroup.computeSenderParentHashes: walk the filtered direct path.
Map each filtered node back to its unfiltered level so the
preUpdateSiblingHashes lookup (indexed by unfiltered level) still
resolves. This makes the parent_hash chain agree with §7.9.2 and
with what openmls computes on the other side.
- MlsGroup.verifyParentHash: short-circuit on empty filtered path
rather than empty unfiltered path.
New regression test: testRemoveMiddleLeaf_ReceiverAcceptsCommit
reproduces the 3-member remove-middle scenario end-to-end within
quartz. It passed before this patch (because both sides were
unfiltered and agreed with each other), and it passes after this
patch (because both sides are now filtered and still agree) — the
compatibility win is that quartz now also agrees with
openmls/wn-produced commits of the same shape.
Also fix an unrelated script bug in marmot-interop.sh: the
discover_a_relays SQL probe was falsely reporting "wn has NO cached
relay entries for A" even when welcome delivery was working, because
the query assumed users.pubkey stored as BLOB but wn stores it as
TEXT (hex) in the current schema. Accept either encoding.