The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.
| Aspect | Before | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method | GET | POST |
| URL | `<base>/<roomId>` | `<base>/auth` |
| Body | none | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response | `{endpoint, token, codec, sample_rate}` | `{token}` only |
| Endpoint | from response | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98 | bound to GET URL | bound to POST URL + body hash |
Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
→ `NestsClient.mintToken(room, publish, signer): String`. The
publish flag drives the JWT claims (`get` for listeners, `put`
for speakers).
Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
NestsRoomConfig` instead of split (serviceBase, roomId), pass
`publish=false` / `publish=true` respectively, and use the room's
`endpoint` (not a server-returned one) for the WebTransport
connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
`Broadcasting` carry the `room: NestsRoomConfig` instead of the old
`roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
bytes are `room.moqNamespace()` — the simplest mapping to the
relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
test will confirm and adjust if the relay expects a multi-segment
tuple.
Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
with `(room: NestsRoomConfig)`. Connector seam interfaces
(NestsListenerConnector, NestsSpeakerConnector) follow the same
shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
`EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
+ roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
`EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
`event.kind` in addition to `event.service()`; rooms missing any
of those are silently un-joinable (the event author didn't host
on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
of (serviceBase, roomId) and threads it down.
Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.
Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.
Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
Lands the audit follow-ups that didn't require external input. Only the
wire-format draft pinning (MoQ #1, #8) remains deferred — that's gated
on the M4 manual interop pass, and the same-room-PIP-re-entry corner
case (Android #5) — Android has no programmatic PIP-exit API.
ViewModel:
- VM #10: serialize disconnect→connect via a tracked `pendingCloseJob`.
teardown() records the listener.close() launch (when not finalCleanup);
the next launchConnect() awaits it before opening a fresh transport.
Eliminates the brief two-QUIC-session overlap that some MoQ relays
reject by deduping on client pubkey.
- VM #4: extracted shared connect-launch body into `launchConnect(triggerRetryOnFailure)`
so connect() / connectInternal() share one implementation. Removed
the near-duplicate viewModelScope.launch block.
- VM #8b: setMicMuted no longer silently swallows handle failures.
BroadcastUiState.Broadcasting gains a `muteError: String?` field that
the UI can surface as an inline message; cleared on the next successful
toggle. The broadcast itself stays running with its previous mute
state — only the mute toggle failed.
- VM #6: documented the dispatcher-confinement contract in the class
kdoc. Audit was theoretical — every map mutation already runs on
viewModelScope (Dispatchers.Main.immediate on Android, same dispatcher
the MoQ flow's onEach callback uses because the player launch lives in
viewModelScope). Future cross-thread callers must marshal explicitly.
Android:
- Android #2: AudioRoomActivity.toggleMuteSignal type tightened from
`MutableSharedFlow<Unit>` to `SharedFlow<Unit>` so external code can't
tryEmit into it. Internal emit uses the private `_toggleMuteSignal`.
- Android #10: presence debounce-publisher (the LaunchedEffect keyed on
micMutedTag) now skips entirely when micMutedTag is null. Stops the
duplicate first-frame publish where heartbeat fires immediately AND
debounce-publisher fires 500 ms later, both with muted=null. Once the
user goes live the debounce-publisher kicks in for state changes.
MoQ session:
- MoQ #11: send() rollback comment rewritten to say "monotonic; gaps
acceptable per spec, this just minimises them on full-fanout failures"
instead of the misleading "strictly contiguous" claim.
Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.
Still deferred:
- MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs
M4 interop input from `nostrnests.com` to know what the relay actually
speaks.
- Android #5: same-room re-entry from MainActivity while in PIP doesn't
auto-exit PIP. Android has no programmatic PIP-exit API; user must tap
the expand button. Corner case.
- Test coverage gaps (round-1 VM #10, round-2 VM #13): retry-counter +
broadcast state + setMicMuted-no-handle + server-Closed cleanup +
double-connect-while-Failed. Each is a small dedicated test using the
existing connector-seam pattern; landing as a separate test-only commit.
Round 2 audit (3 parallel agents reviewing every change since the
previous follow-up commit) caught one CRITICAL regression and several
HIGH/MED items. Most round-1 fixes verified clean.
CRITICAL fix (audit round-2 MoQ #1):
- Pump exception handlers added in the previous commit call `close()`
from inside the failing pump's own coroutine. `close()` now does
`controlPumpJob?.join()` to drain in-flight writes — but the Job we
try to join is the very Job we're inside, so `join()` suspends
forever (lambda can't finish until close returns; close can't
return until lambda finishes). `runCatching` doesn't help — `join()`
doesn't throw, it suspends. This deadlocks the entire session
whenever a pump fails.
Fix: skip the join when the current coroutine IS the job we're
joining. `currentCoroutineContext()[Job]` identifies the caller; we
compare and bypass.
HIGH fixes:
- MoQ #2 (regression): concurrent unannounce() + post-OK
AnnounceError handler could both write UNANNOUNCE on the wire (some
relays disconnect on UNANNOUNCE for an unknown namespace). Fix:
`AnnounceHandleImpl.unannounceWritten: Boolean` flag, set under
stateMutex by whichever writer goes first; the other path skips.
- VM #3 (new): `connect()` overwrote `listener` if invoked from a
Failed-with-stale-listener state, leaking the previous MoQ session.
Fix: call `teardown(targetState=Idle, finalCleanup=false)` before
launching the new attempt when listener or stateObserverJob is
still alive.
- VM #7 (new): `openSubscription` allocated decoder + player via the
factories, then attached them to the slot. If the VM scope was
cancelled between `decoderFactory()` and `slot.attach(...)`, the
native MediaCodec / AudioTrack leaked because nothing was tracking
them yet. Fix: nest a try/catch that releases both on any throw
(including `CancellationException`) before re-throwing.
MED fixes:
- MoQ #7 (new): `capture.start()` could throw before `job` was
assigned, leaving the broadcaster in a half-started state where
future `start()` calls would re-pass the guards and double-start
the mic. Fix: try/catch around capture.start; on throw, set
`stopped = true` + run capture.stop and propagate.
- MoQ #8 (new): `stopped` was read across threads (setMuted from
caller, stop from anywhere) without a `@Volatile` barrier.
Visibility hazard. Fix: `@Volatile private var stopped`.
- Android #12 (new): after the user granted RECORD_AUDIO via the
Settings deep-link, `permissionDenied` stayed `true` because the
launcher callback never fired — the warning + Open-settings button
remained visible until the user tapped Talk again. Fix: derive
`showDenialWarning` from `permissionDenied AND
ContextCompat.checkSelfPermission(...) != GRANTED`. Re-checks every
recomposition (including post-Settings return).
Round-1 fixes verified clean by this audit:
- pending-deferred completeExceptionally on close
- SubscribeDoneStatus codes (UNSUBSCRIBED=0x00, TRACK_ENDED=0x03)
- suspend `stop()` conversions on broadcaster + player
- gate release before NestsSpeaker teardown chain
- unannounce() ordering on thrown wire-write
- SharedFlow PIP signal (rapid double-tap behavior is correct)
- RECEIVER_NOT_EXPORTED gate (constant 4 doesn't collide; round-1
collision claim was incorrect)
- onUserLeaveHint guards (PIP from lobby, no PIP support)
- foreground service `startForeground`-always-first contract
(`Result.onFailure` is `inline`, the `return` IS a non-local
return from `onStartCommand` — verified)
- 4-hour wake-lock cap
- AudioRoomBridge.clear() in AccountViewModel.onCleared
Still deferred:
- VM #6 (round-1 carryover): unsynchronized speakingExpiryJobs map.
Cross-thread mutation under contention. Needs ConcurrentHashMap or
Dispatchers.Main.immediate marshalling.
- VM #10 (round-2 new): brief two-QUIC-session overlap during
rapid disconnect→connect.
- Android #5 (round-2 new): same-room re-entry from MainActivity
while in PIP doesn't auto-exit PIP.
- MoQ #1, #8 (round-1 carryover): wire-format draft pinning. Still
blocked on M4 manual interop input.
Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.
Round 2 of the audit-driven cleanup. Lands every HIGH and most MED
findings from the protocol / ViewModel / Android lifecycle audits that
weren't fixed in the previous commit. The wire-format draft pinning
(audit MoQ #1, #8) is intentionally deferred until the M4 manual interop
pass against `nostrnests.com` reveals which draft revision the relay is
actually speaking.
MoQ session fixes:
- #5 UNANNOUNCE wire-write now happens BEFORE removing announces[ns],
so an inbound SUBSCRIBE during the teardown window sees the namespace
as withdrawn (sessionClosed=true → SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST))
instead of "namespace never existed".
- #4 dispatchControlMessage(AnnounceError) now distinguishes pre-OK and
post-OK errors. Post-OK is a session-level kick: mark the handle
closed, send UNANNOUNCE, then drop. Pre-OK still rolls back the
optimistic announces[] insert as before.
- #6 close() now joins the cancelled control + datagram pumps before
calling controlStream.finish(), so an in-flight SUBSCRIBE_OK /
SUBSCRIBE_DONE / SUBSCRIBE_ERROR write can complete its
writeMutex.withLock { ... } critical section. Previously cancellation
could truncate a frame mid-flight and we'd send FIN over a corrupted
stream.
- #9 pumps wrapped in try/catch that calls close(...) on unexpected
exceptions, so a transport-died-mid-session no longer leaves the
session thinking it's healthy with new subscribe/announce calls
hanging on a dead peer.
- #10 TrackPublisher.send rolls back nextObjectId when every datagram
fan-out fails (transport down). The audio-rooms NIP wants strictly
contiguous object ids per group; a gap from a fully-failed send
would trip strict subscribers.
- #13 DefaultNestsSpeaker.close drops `gate` before calling
activeHandle.close() / session.close(). The teardown chain runs
cancelAndJoin on the broadcaster + sends SUBSCRIBE_DONE per attached
subscriber + joins MoQ pumps; holding the gate through all of that
blocked any other concurrent API call on this speaker.
Resource lifecycle (audit MoQ #11/#12):
- AudioRoomBroadcaster.stop() now `cancelAndJoin`s the loop before
releasing the encoder + closing the publisher. The loop's last
encoder.encode/publisher.send no longer races
encoder.release()/publisher.close() — both produced use-after-release
on native MediaCodec on Android, the latter sent orphan
OBJECT_DATAGRAMs to subscribers we'd just told SUBSCRIBE_DONE.
- AudioRoomPlayer.stop() promoted to `suspend` + cancelAndJoin for the
same reason: decoder.release() ran while the decode loop was still
inside MediaCodec.decode(...), undefined behaviour. Updated VM call
sites (closeSubscription, teardown) to route both player.stop() and
handle.unsubscribe() through one launched coroutine via the new
`detach(): Pair<AudioRoomPlayer?, SubscribeHandle?>` shape.
ViewModel fixes:
- #4 auto-retry uses a single `retryPending: Boolean` flag instead of
`Job.isActive`. Two scheduleAutoRetry calls could previously both
pass `Job.isActive == false` (the launched body had just started)
and stack a second retry on top of one already running.
- #7 setMicMuted updates the UI INSIDE the launched coroutine, after
the suspending broadcastHandle.setMuted() returns. Previously the
indicator could claim "muted" while audio was still on the wire if
the handle's setMuted suspended on a gate.
- #8 connect() cancels the previous stateObserverJob before kicking
off the new attempt, so a delayed Failed/Closed emission from the
old listener can no longer clobber the fresh Connecting UI.
- #9 disconnect() clears requestedSpeakers, so a fresh connect() to a
different room (or the same room after a long pause) doesn't reuse
a stale speaker snapshot.
- #12 updateSpeakers filters out the user's own pubkey: subscribing
to your own forwarded audio would echo through the local playback
device whenever the broadcast track loops back from the relay.
Android lifecycle / PIP / service:
- #6 PIP aspect ratio flipped from 9:16 (portrait sliver) to 16:9
(landscape) so the row of avatars under the title actually fits.
- #7 process-death recovery: when AudioRoomBridge is empty (previous
process's AccountViewModel is gone), redirect to MainActivity
before finish() so the user lands somewhere meaningful instead of
a black-flash.
- #8 AudioRoomForegroundService.onStartCommand always calls
startForeground first, on every invocation including ACTION_STOP.
startForegroundService's 5-second contract requires it; previously
the STOP path skipped it. startForeground itself wrapped in
runCatching so a foreground-not-allowed exception bails cleanly
rather than leaking the wake-lock.
- #10 wake-lock timeout reduced 12 h → 4 h. Stuck connections that
fail to detect a network drop no longer hold the device awake for
half a day.
- #11 presence-event spam fix: split the publish loop into a
heartbeat keyed only on (address, handRaised) and a separate
debounced state-change publisher keyed on micMutedTag. Every mute
toggle previously triggered a full sign + publish + relay round
trip; now we coalesce within a 500 ms window.
- #12 final "leaving" presence routed through GlobalScope.launch
instead of rememberCoroutineScope (which is cancelled on dispose,
so the leave event almost never reached the relay).
- #14 RECORD_AUDIO denial recovery: when the user has tapped "Don't
ask again", the launcher silently returns false. New "Open
settings" button deep-links to the app's settings page so the user
can re-grant the permission and try again.
- #15 setPictureInPictureParams now updates outside PIP too, so the
next entry shows the correct mute-state icon without an extra flip.
- #16 onNewIntent override: a second Join tap for a different room
finishes the current Activity and starts a fresh one with the new
extras, instead of singleTask silently keeping the old room
running.
Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.
Audit findings still deferred (all documented inline / in this commit):
- MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs
the M4 manual interop pass to confirm what nests is actually speaking.
- VM #6: confined map mutation under a single dispatcher. Current
setup (Dispatchers.Main via setMain in tests, viewModelScope in
prod) is functionally fine; full belt-and-suspenders confining is a
separate concurrency review.
- VM #10: test coverage gaps for retry + speaker reconcile cycle +
setMicMuted no-handle case + server-initiated Closed leaves stale
state. Each is a dedicated test.
- Android #18: startListening / promoteToMicrophone race. Mitigation
(always declaring microphone foreground type) requires unconditional
RECORD_AUDIO grant which listener-only users won't have.
Adds an end-to-end MoQ round-trip test and lands the highest-severity
findings from a 3-agent audit (protocol / ViewModel / Android lifecycle)
of the M5–M7 + Activity work.
Round-trip test (`:nestsClient` MoqRoundTripTest):
- Two MoqSession.client() instances (publisher + subscriber) talk
through a hand-rolled in-test relay coroutine that mirrors a real
MoQ relay's wire behavior (forwards CLIENT_SETUP / SERVER_SETUP /
ANNOUNCE / SUBSCRIBE / SUBSCRIBE_OK / OBJECT_DATAGRAM).
- 100-Opus-frame test exercises the full publisher → wire →
subscriber path, asserting payload bytes + monotonic group/object
ids round-trip correctly. Catches any drift between our publisher
and our own subscriber's wire format.
- Second test verifies SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) flows
back as MoqProtocolException when the publisher hasn't openTrack'd.
MoQ protocol fixes (CRITICAL audit findings):
- SubscribeDoneStatus constants were inverted: had UNSUBSCRIBED=0x01
(peer reads as INTERNAL_ERROR) and TRACK_ENDED=0x00 (peer reads as
UNSUBSCRIBED). Swapped to draft-stable values: UNSUBSCRIBED=0x00,
TRACK_ENDED=0x03.
- Pending CompletableDeferreds for in-flight SUBSCRIBE / ANNOUNCE on
session close were `cancel()`-ed, which propagates as
CancellationException — caller's entire scope cancels instead of
catching a domain MoqProtocolException. Switched all sites to
`completeExceptionally(MoqProtocolException("session closed"))`
including unsubscribe-while-pending-OK.
ViewModel fixes:
- openSubscription race: re-check `activeSubscriptions[pubkey] === slot
&& !closed` AFTER the suspending `subscribeSpeaker` returns; if the
user removed the speaker mid-flight, fire-and-forget UNSUBSCRIBE
rather than attaching a leaked SubscribeHandle + AudioRoomPlayer to
a discarded slot.
- Server-initiated `Closed` listener state now triggers
`teardown(targetState=Closed)` and resets the auto-retry counter,
so a transport-died-mid-handshake doesn't leave stale subscriptions
in the VM map until the Activity finishes.
- Cleanup-scope split: `disconnect()` (user-driven) routes the close
through `viewModelScope` (still alive); `onCleared()` routes it
through a process-lived `cleanupScope` (default GlobalScope, tests
pass backgroundScope) so MoQ control frames (UNSUBSCRIBE,
UNANNOUNCE, SUBSCRIBE_DONE) actually land before the QUIC
transport drops, instead of being eaten by the cancelled
viewModelScope.
Android lifecycle fixes:
- AudioRoomBridge.clear() wired into AccountViewModel.onCleared next
to the existing CallSessionBridge.clear() — no more cross-account
AccountViewModel leak after logout/switch.
- registerReceiver flag now gated on Build.VERSION.SDK_INT
TIRAMISU+ — RECEIVER_NOT_EXPORTED on pre-33 devices collides with
RECEIVER_VISIBLE_TO_INSTANT_APPS. PendingIntents stay
package-scoped via setPackage(packageName).
- onUserLeaveHint no longer enters PIP unconditionally: gated on
ui.connection == Connected AND PackageManager
FEATURE_PICTURE_IN_PICTURE present, so PIP-from-lobby /
PIP-on-incompatible-device doesn't leave a frozen full-screen
card in Recents.
- Replaced the process-wide singleton AudioRoomPipActions toggle
Boolean with a per-Activity MutableSharedFlow<Unit> so a stale
emission from a torn-down Activity can't leak into a new one.
ViewModel test was extended with `cleanupScope = backgroundScope`
plumbing so the post-disconnect `listener.close()` assertion remains
observable in the test scheduler.
Verified: spotlessApply clean; :commons:jvmTest (9 tests),
:nestsClient:jvmTest (80 tests including 2 new round-trip), and
:amethyst:compilePlayDebugKotlin all green.
Audit findings deferred to follow-up commits (none ship-blocking):
- Wire layout pinning vs draft-17 vs draft-11 (currently emits a
draft-11-style OBJECT_DATAGRAM; we advertise draft-17). Resolve
via the M4 manual interop pass against nostrnests.
- UNANNOUNCE racing inbound SUBSCRIBE; control-pump cancel half-write
(audit MoQ #5, #6) — small ordering tweaks.
- Two retry coroutines stacking under fast Failed bursts (audit VM #4).
- AudioRoomForegroundService startForeground always-first contract
(audit Android #8).
M8a — presence event reflects mic-mute state:
- AudioRoomStage's `publishPresence` now passes the broadcaster's
current mic state into the kind 10312 `muted` tag: `null` when not
broadcasting (no mic to be muted on), explicit `true` / `false` while
the speaker path is `Broadcasting`. Other clients can now render a
mute indicator on our avatar.
M8b — auto-reconnect with capped exponential backoff:
- AudioRoomViewModel detects `NestsListenerState.Failed` and schedules a
retry after 1s, 2s, 4s, ..., capped at 16s. Up to 3 attempts before
the UI stays in Failed for a manual retry.
- User-initiated `connect()` and `disconnect()` reset the retry counter
so manual recovery starts fresh.
M8c — iOS:
- Skipped: neither `:nestsClient` nor `:quic` declares an iOS target
yet, so there's no iosMain source set to populate. When iOS lands,
the audio capture/playback + transport actuals will need iOS impls
(and the speaker UI will need an iOS shell).
M3 + M9 — foreground service:
- New `AudioRoomForegroundService` (foregroundServiceType
`mediaPlayback|microphone`) anchors the process so audio keeps
playing with the screen off. Holds a partial wake-lock + media-style
notification; the notification's "Stop" action stops the service.
- Lifecycle wired in AudioRoomStage via:
* LaunchedEffect(isConnected, isBroadcasting) on the listener +
broadcast UI state — promotes to mediaPlayback+microphone type
when broadcasting starts (Android 14+ split foreground-type
permission requirement), falls back to mediaPlayback when only
listening, stops entirely when listener drops.
* DisposableEffect(Unit) for screen-exit cleanup.
- The service does NOT own the MoQ session / decoder / player — those
remain in the VM. Screen-off works; "navigate away keeps audio" would
require moving the audio stack into the service, which is a bigger
refactor outside the audio-rooms completion plan's scope.
- Strings: 6 new `audio_room_notification_*` keys.
- Manifest: declares the service with `mediaPlayback|microphone`
foreground type. RECORD_AUDIO + FOREGROUND_SERVICE_MICROPHONE were
already declared.
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
Wires the M5–M7 publisher path into the existing audio-room screen so
hosts and speakers can broadcast their own audio.
ViewModel (commons AudioRoomViewModel):
- New optional constructor params `captureFactory` / `encoderFactory`.
When both are non-null, `canBroadcast = true`; otherwise the speaker
UI is hidden (desktop, listener-only).
- `startBroadcast(speakerPubkeyHex)` — kicks off
`connectNestsSpeaker(...)` + `NestsSpeaker.startBroadcasting()`,
surfaces a `BroadcastUiState.Connecting` → `Broadcasting(isMuted)`
transition.
- `setMicMuted` / `stopBroadcast` — mic-side counterparts to the
listener's `setMuted` / `disconnect`.
- `BroadcastUiState` sealed hierarchy added to `AudioRoomUiState`.
- `disconnect` and `onCleared` tear the broadcast down before tearing
the listener down so SUBSCRIBE_DONE / UNANNOUNCE go out cleanly.
- `NestsSpeakerConnector` test seam mirrors `NestsListenerConnector`.
Screen (AudioRoomStage):
- AudioRoomViewModelFactory now wires the Android speaker actuals
(`AudioRecordCapture` + `MediaCodecOpusEncoder`).
- New `AudioTalkRow` composable: Talk button gated on
`RECORD_AUDIO` (granted via `ActivityResultContracts.RequestPermission`),
Live indicator (red AssistChip) + mic-mute toggle while broadcasting,
Stop talking button. Hidden unless the user is in the room's `p` tags
as host or speaker AND `viewModel.canBroadcast`.
- Permission denial surfaces an inline error message rather than
reprompting; user can re-tap Talk to try again.
- Strings: 8 new `audio_room_*` keys (talk / stop_talking / mic_mute /
mic_unmute / broadcast_connecting / broadcasting / broadcast_failed /
record_permission_required).
`AndroidManifest.xml` already declares `RECORD_AUDIO` (and the
foreground-microphone permission for M9), so no manifest change here.
Verified: `:commons:jvmTest` + `:nestsClient:jvmTest` (78 tests, all
green) + `:amethyst:compilePlayDebugKotlin` clean.
Listener side now subscribes to every host AND speaker on the stage (was
just hosts) and exposes a `speakingNow: ImmutableSet<String>` derived
from MoQ object arrival. Each on-stage avatar gets a primary-color ring
while its track is delivering audio (debounced 250 ms — ~12 Opus
frames — so packet jitter doesn't make the ring flicker).
ViewModel:
- `AudioRoomViewModel.uiState` gains `speakingNow`.
- New `onSpeakerActivity(pubkey)` is invoked once per received MoQ object
via a `Flow.onEach` tap on `SubscribeHandle.objects` (before the
AudioRoomPlayer consumes it). Each invocation (re)arms a per-speaker
expiry coroutine that clears the entry after `SPEAKING_TIMEOUT_MS`.
- speakingNow is cleared whenever the underlying subscription closes
(speaker removed from room) or the listener tears down (disconnect /
onCleared).
Screen:
- AudioRoomViewModel is now hoisted up to AudioRoomStageContent so the
speaker rows can read speakingNow alongside the connection UI.
- StagePeopleRow wraps each ClickableUserPicture in a 2 dp circular
border when the participant is in `speakingNow`.
- updateSpeakers now receives `(hosts + speakers).pubKeys` so dynamic
speaker promotion via NIP-53 event updates flows through to the
reconcile loop already in M1.
Test:
- Adds `speakingNowClearsOnTeardown` to AudioRoomViewModelTest. The
per-frame activity path is exercised end-to-end by the existing
AudioRoomPlayerTest in :nestsClient (which uses the in-memory pipe).
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:amethyst:compilePlayDebugKotlin` green.
Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.
UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
above the existing hand-raise. State chip walks ResolvingRoom →
OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
reason inline with a Retry button. The 30312 `service` tag drives
visibility — rooms hosted on non-nests servers show "Audio not
available" and the rest of the stage UI continues to work.
ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
`NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
whenever the screen pushes a new speaker set via `updateSpeakers`.
Audio-pipeline construction is injected via `decoderFactory` /
`playerFactory` so a future desktop port can reuse the orchestration
once Compose Desktop has WebTransport. Unit-tested with a fake
`NestsListenerConnector` driving state transitions directly.
nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
decode + network pipeline running so unmute is sample-accurate.
Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
`:nestsClient`'s platform source sets and are bound by an
Android-side `AudioRoomViewModelFactory` co-located with the screen.
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
Two bugs that conspired to break interop test 15 once the test 14 OOM
was fixed:
1. **No receive path for standalone PublicMessage proposals.**
wn/openmls publishes a non-admin's `SelfRemove` as a kind:445 carrying
a `PublicMessage(content_type=PROPOSAL)` envelope and waits for an
admin to fold it into the next commit. Quartz's `MarmotInboundProcessor`
answered every such event with `Error("Standalone proposals not yet
supported")` and dropped it. The admin's subsequent commit then
failed with `Commit references unknown proposal (ref not found in
pending proposals)` because nobody had staged the SelfRemove.
Add `MlsGroup.receivePublicMessageProposal(pubMsg)` that:
- rejects mismatched epoch / group_id / sender,
- reconstructs the FramedContentTBS exactly as the proposer did and
verifies the leaf signature,
- verifies the membership_tag against the current epoch's
membership_key (same threat model as inbound PublicMessage commits),
- decodes the inner Proposal (only `SelfRemove` is accepted today —
other types come bundled in commits' `proposals` lists),
- stages the proposal in `pendingProposals` so a later commit can
resolve its `ProposalRef`.
`processPublicMessage` now routes `ContentType.PROPOSAL` through
that helper and returns a new `GroupEventResult.ProposalStaged`
variant (also surfaced as `MarmotIngestResult.ProposalStaged`),
replacing the old hard-error path.
2. **Wrong `ProposalRef` hash input.** RFC 9420 §5.2 specifies that a
`ProposalRef` hashes the **encoded `AuthenticatedContent`** that
delivered the proposal, not the bare `Proposal` struct. Quartz was
hashing `proposal.toTlsBytes()` only — fine for our local-only
flows where commits inline rather than reference our own pending
proposals, but fatal once we needed to match wn's reference to an
inbound proposal.
Extend `PendingProposal` with an optional `authenticatedContentBytes`
field. The standalone-proposal receive path captures the full
`wire_format || FramedContent || FramedContentAuthData` envelope at
stage time. The reference-resolution code in `processCommitInner`
prefers those bytes when present and falls back to the bare-proposal
hash for locally-proposed entries (which never get referenced
today).
Marmot interop score: 14/16 → 15/16 (test 9 — amy's kind:7 reaction
triggers a `SecretReuseError` on B's wn — is unrelated to this path
and remains for follow-up).
https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
Every file under commons/src/commonMain/kotlin/.../commons/ui/chat/
(ChatBubbleLayout, ChatMessageCompose, ChatroomHeader,
DmBroadcastBanner, DmBroadcastStatus, UserDisplayNameLayout) was
imported only by desktopApp. Android has its own parallel
implementation at amethyst/.../chats/feed/, so nothing shared.
Moved the six files into desktopApp/.../ui/chats/ alongside
ChatPane.kt, DesktopMessagesScreen.kt, etc. — same package as every
existing caller, which lets us drop six `import` lines from
ChatPane.kt + DmSendTracker.kt + DesktopMessagesScreen.kt. Empty
commons/ui/chat/ directory removed.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
NoteCard hover (header+text inner clickable + avatar/name chip):
- Clip(RoundedCornerShape(8.dp)) before .clickable on the header+text
Column so the ripple rounds instead of cutting a hard rectangle.
- Avatar+name chip gets a stadium clip (RoundedCornerShape(100.dp))
before its clickable — matches how Slack / Notion / Linear render
user-pill hover states.
Chat bubble hover (ChatBubbleLayout):
- combinedClickable was applied outside the Surface's shape clipping,
so the ripple ignored ChatBubbleShapeMe/Them. Moved the shape into a
named val and added Modifier.clip(bubbleShape) ahead of
combinedClickable. Hover now rounds with the bubble.
Chat reaction icon (ChatPane detailRow):
- On hover the "add reaction" icon used to conditionally appear and
shift every other element in the detail row, causing the whole list
to reflow up or down. Wrapped in a Box with Modifier.alpha that
fades in/out — the icon is always laid out so the row stays fixed.
Button is disabled when not hovered so it's click-through when
invisible.
Search text field:
- Now padded by LocalReadingSidePadding so it stays inside the 720dp
reading column on wide displays (previously the search Row's
fillMaxWidth spanned the whole window after the ReadingColumn
refactor).
- Bumped height from 40dp → 44dp. M3 OutlinedTextField has ~16dp of
internal vertical content padding that was clipping the bodyMedium
placeholder at 40dp. Same change for the chat input.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Three related fixes:
ReadingColumn modifier ordering:
- fillMaxSize().widthIn(max = 720) was locking width to parent before
widthIn could cap it, so the cap had no effect in practice. Switched
to widthIn(max = 720).fillMaxWidth().fillMaxHeight() which now caps
correctly on wide displays. Same fix applied to the three screens
that use Box+widthIn inline (UserProfile, Search, Settings).
Platform-aware Material Symbols weight:
- ProvideMaterialSymbols now takes an optional weight parameter that
defaults to MaterialSymbolsDefaults.WEIGHT (300) so Android keeps
current behavior. Desktop passes PlatformIconWeight.current which
maps:
macOS → 200 (thin, matches SF Symbols stroke)
GNOME → 300 (matches libadwaita symbolic icons)
KDE → 300 (matches Breeze)
Windows → 400 (matches Fluent Icons)
other → 300
Settings section hierarchy:
- "Wallet Connect (NWC)" and "Relay Settings" section headers dropped
from titleLarge (22sp) to titleSmall (14sp) so they're smaller than
the "Settings" screen title (titleMedium 16sp). Restores the
visual hierarchy the user reported inverted.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Filter.match is itself just a boolean predicate, so carrying a Filter and
a separate composition predicate on NewEventMatchingFilter duplicated the
abstraction. Collapse to one predicate:
- NewEventMatchingFilter(predicate, onNew): drops the Filter parameter.
- LocalCache.observeNewEvents(predicate): primary API.
- LocalCache.observeNewEvents(filter): kept as a one-liner that forwards
filter::match, so existing feed/DAL callers are unchanged.
In the dispatcher the freshness check, kind check, p-tag match, and
since cutoff now live in a single lambda, short-circuiting on cheap
kind comparison first. NOTIFICATION_KINDS is now a Set<Int> for O(1)
membership instead of O(n) List.contains — cheap, but on a hot path
that runs for every new cache insertion it pays for itself.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
Rolled the compact header pattern out to the screens the earlier pass
missed. Each one now lives on the same padding/typography rhythm as
Messages: Row(fillMaxWidth, padding horizontal = 12, vertical = 8),
titleMedium for labels, IconButton size = 32dp with 20dp icons tinted
with colorScheme.primary.
- ThreadScreen: back button + "Thread" title. Was headlineMedium with
bottom-only padding; now compact row.
- UserProfileScreen: back + "Profile" title on the left, Edit /
Follow buttons kept on the right (they stay as text buttons — they
represent destructive intent, not icon affordances).
- ArticleReaderScreen: back + "Article" title; zoom % label still
surfaces next to the title when != 100%.
- ArticleEditorScreen: back IconButton + "Article" title (was a text
"Back" OutlinedButton with no title). Save / Publish buttons kept
on the right — same reasoning as UserProfileScreen.
- ChessScreen: back (conditional) + "Chess" / "Live Game" title;
refresh + New Game converted from Button → IconButton so the
header reads at the same scale as the rest.
- RelayDashboardScreen: had no header at all, just a PrimaryTabRow.
Converted Monitor / Configure to FilterChip tabs-first (matching
Feed / Reads) so the selected tab is the title.
FeedHeader relocation:
- Moved commons/ui/feed/FeedHeader.kt → desktopApp/ui/FeedHeader.kt.
Only NotificationsScreen.kt referenced it, and dropping the import
from that file was enough because it's now in the same package.
- Removed the empty commons/ui/feed/ directory.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Refactored the three feed screen headers so they all match the Messages
pattern — single compact row padded horizontal = 12, vertical = 8, no
oversized titles, no multi-row metadata.
FeedScreen (Home):
- Replaced the `headlineMedium` "Global Feed" / "Following Feed" title
plus a redundant Global/Following chip row plus a separate relay-count
meta-line plus a large "New Post" button with:
[ Following | Global ] [relays] [refresh] [+ compose]
The selected tab IS the screen title. Relay count + followed-user
count surface through a hover tooltip on the relays icon (desktop
convention; info is preserved without taking a whole row).
- Relays icon click opens the picker when the account can edit, falls
through to navigate-to-relays otherwise.
ReadsScreen:
- Same treatment: dropped the "Reads" label, lifted Following/Global
chips to the left, single refresh icon on the right. "N relays
connected" moved to the refresh button's content description.
commons/FeedHeader (used by NotificationsScreen):
- Dropped the RelayStatusIndicator (icon + count text + refresh button,
took 3 slots). Now just titleMedium on the left + a single refresh
IconButton on the right with relay count in its content description,
matching the Messages "titleMedium + icon buttons" rhythm.
- Internal padding(horizontal = 12, vertical = 8) baked in so callers
don't need a trailing Spacer.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
The painter measured the glyph with TextStyle(color = Unspecified) and
tried to override the color at draw time via drawText(layout, color = tint).
That override is unreliable across Compose versions: when the measured
style carries Color.Unspecified, drawText can fall through to Color.Black,
producing black-on-black icons (visible on the back arrow in dark mode).
Include the tint in the measured TextStyle directly. The (symbol, fontFamily,
density, tint, rtl) remember key on rememberMaterialSymbolPainter was already
keyed on tint, so the TextMeasurer cache still hits for every (symbol, color)
pair used by the app.
Material Symbol icons were allocating a fresh Font wrapper on every
composition (Font(resource = ...) returns a new instance each call),
which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily
and forced a fresh TextMeasurer per call site. Tint was also baked into
the TextStyle passed to measure(), so any tint change (selected /
unselected reaction icons, hover states) produced a cache miss.
Hoist the FontFamily + TextMeasurer to CompositionLocals provided once
at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop)
via ProvideMaterialSymbols. Every icon in the subtree now reuses:
- One FontFamily (Skia typeface cache hit for every subsequent glyph)
- One TextMeasurer with a 64-entry LRU, shared across all call sites
so its measurement cache is hit across 300+ MaterialSymbols usages
- Tint applied via drawText(layout, color = tint) instead of TextStyle,
so the measured TextLayoutResult is reused across tint variations
Icon(symbol = ...) now delegates to Material3's Icon(painter = ...),
restoring proper sizing/semantics without the custom Box+drawBehind
workaround. autoMirror handled inside the painter's onDraw. The
previously-unused MaterialSymbolPainter is now the single render path.
The weight parameter is dropped from the Icon/Painter APIs since the
app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT).
A local fallback path keeps @Preview composables that don't wrap in
the theme renderable (per-composition allocation, same cost as before).
https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
Audit of every notify() path found self-exclusion + 15-min age checks
duplicated across nearly all of them, with two inconsistencies (DM kind 4
and zap kind 9735 were missing explicit self-checks). Reactions, zaps,
and chess also re-ran isTaggedUser even though consumeFromCache already
routes by the same `p` tag.
Collapse the duplication into two layers:
- Observer layer (NotificationDispatcher): 15-min rolling age cutoff is
now enforced by a predicate on LocalCache.observeNewEvents, before any
account routing happens. The Nostr Filter grammar's `since` is a fixed
value from dispatcher start; the predicate re-evaluates TimeUtils.
fifteenMinutesAgo() per event so the window rolls forward as wall-clock
time advances.
- Per-account layer (dispatchForAccount): right after the call/wake-up
branch and the MainActivity.isResumed gate, drop events authored by
the current account. Kept per-account (not observer-wide) because in a
multi-account session account A's outgoing event legitimately becomes
account B's incoming notification on the same device, so a device-wide
author-exclusion set would swallow A→B.
Mechanically, NewEventMatchingFilter now takes an optional predicate so
observers can reject on fields the Filter grammar can't express (like a
moving `createdAt` cutoff). LocalCache.observeNewEvents adds a matching
overload that forwards it.
With the gates centralized, every notify() method drops its own age and
self-author checks (and for reaction/zap, the redundant isTaggedUser).
notifyWelcome keeps its own guards because it's dispatched directly
from processMarmotWelcomeFlow, bypassing the observer and dispatchForAccount.
Calls and wake-ups also bypass the shared gates by their short-circuit
return before the shared block.
https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
The Canvas-based MaterialSymbol Icon collapsed to 0x0 whenever callers
didn't pass an explicit size modifier (e.g. ArrowBackIcon, ClearTextIcon).
Canvas is a Spacer + drawBehind, and Spacer's measure policy only uses
maxWidth/maxHeight when the incoming constraints are fixed. Inside an
IconButton (state layer 40dp, content constraints 0..40), defaultMinSize
only lifts minWidth/minHeight, leaving the constraints unfixed - so
Spacer picked 0x0 and the glyph never drew. Icons that passed
Modifier.size(N.dp) worked only because size(...) produces fixed
constraints.
Swap Canvas for an empty Box with drawBehind. Box's EmptyBoxMeasurePolicy
uses minWidth/minHeight, so defaultMinSize(24, 24) now takes effect when
no size modifier is supplied - matching Material3's own Icon behavior.
https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
The quartz-layer companion (testLeaveAndRejoin_SameGroupIdEndToEnd in
MlsGroupManagerTest) only exercises the MLS engine. This one goes one
layer up and drives the full commons-layer pipeline end to end:
- NIP-59 gift wrap / unseal of the Welcome (kind:1059 -> kind:13 ->
kind:444) via MarmotManager.ingest,
- ChaCha20-Poly1305 outer + MLS PrivateMessage decryption of the group
event (kind:445) via the same ingest entrypoint,
- persisted inner-event log via MarmotMessageStore (asserts log is
wiped on leave and does not resurrect pre-leave messages on rejoin),
- subscription bookkeeping on MarmotSubscriptionManager (asserts
subscribe on join, unsubscribe on leave, re-subscribe on rejoin),
- KeyPackage bundle lifecycle via KeyPackageRotationManager +
KeyPackageBundleStore (two distinct KPs generated for the two joins).
Same admin-kick workaround as the quartz-layer test: the standalone-
proposal ingestion path in MarmotInboundProcessor still returns
"Standalone proposals not yet supported", so the test has Alice
commit the Remove directly and documents that inline.
Self-contained in-memory stores live alongside the test so we don't
need to cross-depend on quartz's test utilities. Runs under commons
androidHostTest (same source set that already pulls in secp256k1
JVM bindings for real crypto).
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
- Replace R.drawable.ic_chess (custom drawable) with MaterialSymbols.ChessKnight
(U+F25E) in the navigation drawer's Chess menu entry. Knight is the most
recognizable chess piece silhouette.
- Drop ic_chess.xml.
Chess board pieces (ChessPieceVectors.kt, CBurnett set) stay as ImageVector —
Material Symbols glyphs are monochrome and can't do the white-with-black-outline
style that keeps pieces visible on both light and dark squares.
https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
- GenericCommentPostScreen + ShortNotePostScreen: replace the "post anonymously"
indicator (R.drawable.incognito) with MaterialSymbols.NoAccounts (U+F03E) —
standard Material Symbols glyph, semantically matches "no identity attached".
- ToggleNip17Button.kt: the Nip17Indicator composable it defined had no callers.
Delete the whole file.
- Drop incognito.xml (no remaining consumers).
https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
The NotificationRelayService consumes events directly into LocalCache, which
made EventNotificationConsumer's hasConsumed dedup check short-circuit every
subsequent push-notification path. As a result, no notifications fired while
the service was active.
Rework the notification pipeline so every delivery source (FCM, UnifiedPush,
Pokey, active relay subscriptions, NotificationRelayService) feeds LocalCache
and a single dispatcher observes it for notification-relevant kinds. Foreground
suppression is scoped to MainActivity (PiP/Call activities keep notifying),
with carve-outs for CallOffer and WakeUp so time-sensitive events always fire.
- Add NewEventMatchingFilter observable primitive alongside
EventListMatchingFilter for per-event emission (no list accumulation).
- Add LocalCache.observeNewEvents<T>(filter) backed by the new observable.
- Add MainActivity.isResumed flag flipped in onResume/onPause.
- Add NotificationDispatcher that observes LocalCache for GiftWrap,
EphemeralGiftWrap, PrivateDm, LnZap, Reaction, Chess, and WakeUp.
- Replace EventNotificationConsumer.consume / findAccountAndConsume with
consumeFromCache, which skips the hasConsumed check (the observer itself
provides first-delivery semantics) and gates non-priority kinds on
MainActivity.isResumed.
- Rewire FCM/UnifiedPush/Pokey receivers to feed LocalCache directly.
- Wire the dispatcher into AppModules lifecycle.
- BookmarkGroupItem: replace R.drawable.post with MaterialSymbols.News
(codepoint U+E032). Drop now-unused post.xml.
- IncognitoBadge: drop the NIP-17 "encrypted" indicator (encryption is the
expected default; no need to flag) and replace the legacy NIP-04
unencrypted indicator with MaterialSymbols.NoEncryption (U+F03F).
Drop now-unused incognito_off.xml.
incognito.xml is kept — still used by the "post anonymously" toggle in
ToggleNip17Button, GenericCommentPostScreen, and ShortNotePostScreen.
https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
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
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
Moves five JVM-only upload helpers from desktopApp into commons so
the CLI can reuse the same Blossom upload + AES-GCM encryption path
without depending on :desktopApp:
desktop/service/upload/DesktopBlossomAuth.kt → commons/service/upload/BlossomAuth.kt
desktop/service/upload/DesktopBlossomClient.kt → commons/service/upload/BlossomClient.kt
desktop/service/upload/DesktopMediaCompressor.kt → commons/service/upload/MediaCompressor.kt
desktop/service/upload/DesktopMediaMetadata.kt → commons/service/upload/MediaMetadata.kt
desktop/service/upload/DesktopUploadOrchestrator.kt → commons/service/upload/UploadOrchestrator.kt
API tweaks:
- Drop the `Desktop` prefix on every class.
- Rename the `DesktopMediaMetadata` object to `MediaMetadataReader`
to avoid colliding with the `MediaMetadata` data class in the same
package.
- BlossomClient no longer reaches into desktop's `DesktopHttpClient`.
Callers pass an `OkHttpClient` (or use the default one-shot client);
desktop continues to inject its Tor-aware client.
- Tighten BlossomClient's response handling — the OkHttp version on
commons' classpath has a nullable `Response.body`, so explicitly
null-check it.
Mechanical updates to keep desktop building:
- ComposeNoteDialog / ChatPane / DesktopUploadTracker switch to the
new commons imports.
- DimensionTag-from-MediaMetadata: cross-module smart cast no longer
works on the public `width`/`height` properties — replace with
`?.let { … }` chains.
- commons/jvmMain now declares `commons-imaging` (used only by
MediaCompressor's EXIF stripping).
Tests come along too: file names follow the renamed classes
(spotless ktlint enforces this).
Next commit will add `amy dm send-file --file PATH --server URL`,
which calls `UploadOrchestrator.uploadEncrypted(...)` directly.
Replace the `material-icons-extended` library with Google's Material Symbols
variable font, rendered via a new `commons/icons/symbols/` package. Weight is
set to 100 (Thin) via a single tunable default (MaterialSymbolsDefaults.WEIGHT),
so we can re-tune line thickness globally in one line without regenerating
assets.
Key changes:
- commons/composeResources/font/material_symbols_outlined.ttf: Material Symbols
Outlined variable font (all 3600+ glyphs, 4 design axes: wght/FILL/GRAD/opsz).
- commons/icons/symbols/: MaterialSymbol data class, 219 codepoint constants,
variable-font FontFamily helper, Canvas-backed Icon composable (overload that
accepts MaterialSymbol and supports auto-mirror for RTL), and a Painter
wrapper for use with Image / AsyncImage.
- Bulk-rewrote 250+ call sites across amethyst/, commons/, desktopApp/:
Icons.{Default,Outlined,Filled,Rounded,AutoMirrored.*}.X → MaterialSymbols.X
or MaterialSymbols.AutoMirrored.X. `imageVector =` renamed to `symbol =`
inside Icon() calls; Image(imageVector = MS.X) converted to
Image(painter = rememberMaterialSymbolPainter(MS.X)).
- Dropped material-icons-extended dependency from commons, desktopApp,
amethyst build files.
Hand-drawn custom icons (Bookmark, Like, Reply, Zap, chess pieces, robohash)
remain as ImageVector and are untouched.
https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
Three call sites did the same two-layer peel —
kind:1059 → (nip44 decrypt) → kind:13 sealed rumor → (nip44 decrypt) → rumor
Extracted as `GiftWrapEvent.unwrapAndUnsealOrNull(signer)` in
commons/relayClient/nip17Dm/, and collapsed:
- commons/marmot/MarmotIngest.kt — was an inline `when` switch; now a
one-liner. Behaviour unchanged (still passes through if the inner
isn't a seal, matching the previous defensive `else -> inner` branch).
- desktopApp/Main.kt — dropped the nested unwrap/unseal block in the
kind:1059 case of the LocalCache dispatcher.
- cli/DmCommands.kt — replaced the manual chain in `decryptChatMessages`.
Android's DecryptAndIndexProcessor keeps its two separate handlers
(processNewGiftWrap / processNewSealedRumor) because the LocalCache
pipeline re-enters on each peel — that's a different shape and not
touched.
No behaviour change on any platform.
Pure function with no Android dependencies — relocating it to
commons/relayClient/nip17Dm/ so cli/ can reuse it for NIP-17 DM
subscriptions without pulling in :amethyst. Android caller updated
to import from the new package.
Prep for `amy dm send|list|await` — see cli/plans/2026-04-23-nip17-dm.md.
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.
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.
WhiteNoise threads its kind:445 payloads across three inner kinds:
kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The
Amethyst ingest pipeline was routing every inner event into the group
chatroom feed, so a kind:7 reaction rendered as a chat bubble whose
only content was the emoji, quoting the liked message via the target
`e` tag. That looked identical to a threaded reply. The actual kind:9
reply from `wn messages send --reply-to` never arrived, which made the
two look swapped in the UI.
Three changes to untangle this:
- MarmotGroupList.addMessage/restoreMessage now skip inner events with
kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`.
The reaction is still consumed by LocalCache so it attaches to the
target note's reaction row, and the deletion still revokes that
reaction — they just don't appear as standalone bubbles.
- LocalCache.computeReplyTo learned to derive thread parents for
`ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the
existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies;
without this the reply bubble had no quote context in the feed.
- tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes
its `reply_to` field through clap v4, which renames snake_case to
kebab-case by default. The script was passing `--reply_to`, which
clap rejected; the `|| true` + redirected stderr hid the error and
no reply was ever published. Use `--reply-to`.
https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32
Before, every Marmot group landed unconditionally under the Known tab of the
Messages screen while the New Requests tab ignored groups entirely, so an
invite from a stranger appeared next to real conversations. The dedicated
Marmot group list screen split by ownerSentMessage only, so unreplied groups
where an admin was followed still showed as New Requests.
Replace both splits with a shared MarmotGroupChatroom.isKnown(followingKeySet):
- user has replied -> Known
- no known members and no messages -> New Requests
- otherwise Known iff any admin is in the follow set
Wire the combined Messages feeds (ChatroomListKnownFeedFilter and
ChatroomListNewFeedFilter) through the helper, invalidate dmNew on group list
changes so empty groups flow into New Requests, and have
MarmotGroupListScreen observe kind3FollowList so newly followed admins move
groups between tabs immediately.
MIP-03 ("Application Messages" → Security Requirements) is explicit:
> "Inner events MUST remain unsigned (no `sig` field)
> This ensures leaked events cannot be published to public relays."
Authentication comes from (a) MLS framing — the sender's LeafNode
credential signs the outer MLS Application message — and (b) the
mandatory check in MarmotInboundProcessor.processPrivateMessage that
the inner event's `pubkey` equals the MLS sender's credential identity.
The Nostr Schnorr signature is redundant, and leaving it populated
means a leaked plaintext can be replayed as a valid public kind:9.
whitenoise-rs is spec-compliant (builds via `UnsignedEvent::new()` +
`ensure_id()`, never calls sign). Amethyst was the non-conforming side
on both directions:
1. Send path (`MarmotManager.buildTextMessage`,
`AccountViewModel.sendMarmotGroupMediaMessage`) called
`signer.sign(template)`, producing a signed rumor that shipped a
valid Schnorr signature through the encrypted channel. Switch both
to `RumorAssembler.assembleRumor` — same id derivation (SHA-256
over canonicalized [0, pubkey, createdAt, kind, tags, content]),
but `sig = ""`.
2. Restore path (`Account.kt` on startup reload of persisted inner
events) called `cache.justConsume(innerEvent, null, false)`, which
routed through `consumeRegularEvent` → `justVerify` → `event.verify()`
→ FAIL on empty sig → Note registered with `event = null`, message
never rendered. Pass `wasVerified = true`, matching what the live
receive path already does after the previous commit (573c5c2b).
Existing on-disk persisted messages from older signed-rumor builds
still load — `wasVerified=true` skips sig verify entirely, so both
legacy signed and spec-correct unsigned rumors deserialize cleanly.