The replay=64 SharedFlow fix in d6517cf did NOT close the receiver's
"announced=0" symptom — the next two-phone repro (15:20:08 onward)
still shows the cliff detector ticking with `active=1 announced=0`
indefinitely, even after the session-internal pumpAnnounceWatch
clearly received an Active update. Either moq-rs sends Active to
only the FIRST announce bidi (so the VM-level second bidi never
gets it), OR the chain from that bidi to `_announcedSpeakers` is
broken at a layer my last fix didn't visit.
Adds focused logging at every step of the announce chain so the
next repro pins down which one. All NestRx-tagged:
`MoqLiteSession.announce` (per-bidi pump):
- "session.announce(prefix='…') bidi opened, pump launching"
- "session.announce(prefix='…') bidi pump emit #N status=… suffix='…' chunks=…"
- "session.announce(prefix='…') bidi.incoming() ended naturally chunks=… emits=…"
- per-emission so we can compare bidi#1 (session-internal) vs
bidi#2 (VM-level) emit counts. If bidi#2 has 0 emits, the
relay is sending Actives to only one bidi.
`MoqLiteNestsListener.announces`:
- "MoqLiteNestsListener.announces flow STARTING (state=…)"
- "MoqLiteNestsListener.announces opened bidi#2 (VM-level)"
- per-emission "bidi#2 #N status=… suffix='…'"
- "bidi#2 collect ENDED naturally" / "finally emissions=N"
`ReconnectingNestsListener.announces` (wrapper):
- "wrapper.announces() flow starting collect on activeListener"
- "wrapper.announces() iter=N activeListener=set/null"
- "wrapper.announces() iter=N inner state=Connected/Closed/…"
- "wrapper.announces() iter=N inner collect ended naturally/X fwd=N"
`NestViewModel.observeAnnounces`:
- "observeAnnounces launching collect on l.announces()"
- per-emission "observeAnnounces #N active=… pubkey='…' → ADD/REMOVE"
- "observeAnnounces collect ENDED naturally/X (emissions=N,
_announcedSpeakers.size=…)"
After the next repro, the receiver-side log will show one of:
- bidi#2 never opens (terminalOrConnected != Connected somehow)
- bidi#2 opens, no chunks/emits → relay-side issue
- bidi#2 emits, but `MoqLiteNestsListener.announces` doesn't
forward → bug in the flow body
- forwarding works but observeAnnounces collect ends → bug in
`ReconnectingNestsListener.announces` collectLatest behavior
- everything works but `_announcedSpeakers.size=0` → mutation lost
Tests: full suite still passes (CliffDetectorTest 12, NestPlayerTest
12, NestBroadcasterTest 6, MoqLiteSessionTest 11, …).
The two-phone repro on commit cb61082 showed the cliff still triggers
at ~13 s with framesPerGroup=10 (last drainOneGroup at ts 14:26:14,
broadcaster keeps pushing through 14:26:25+ unimpeded), but the cliff
detector did NOT fire — no `cliff-detector: announced+subscribed but
silent…` log in the receiver's NestRx output despite the gap clearly
exceeding the 4 s threshold. Three changes here narrow the gap.
1. Extract `computeStalledSpeakers` as a pure top-level internal
function over `Map<String, TimeMark>`. The detector loop now calls
it; the rest of the predicate logic (cooldown, announced ∩ active
∩ stale, ≥ inclusive boundary) is now testable without standing up
the NestViewModel coroutine machinery.
2. New `CliffDetectorTest` (commonTest, 12 tests) drives
`computeStalledSpeakers` with a `kotlin.time.TestTimeSource`.
Pins the boundary cases the next refactor would otherwise drift
on: at-threshold inclusive, just-under empty, no-frame-yet ignored,
not-announced ignored, multi-speaker classification, cooldown
suppress, cooldown release, and a few more. All 12 pass.
3. Add diagnostic logging to the live cliff detector. The two-phone
logs proved the predicate's logic works (the `lastFrameAt` mark
was clearly aged past the threshold), so the silence has to be
in the gating: `listener` null, connection state not Connected,
or `_announcedSpeakers` not containing the pubkey at scan time.
New logs:
- "cliff-detector launched" once on start
- "cliff-detector tick=N gated: listener=… conn=…" every 5 s
when gated
- "cliff-detector tick=N active=… announced=… lastFrameAges=…"
every 5 s when running
- "cliff-detector EXITED after N ticks (closed=…)" on cancel
so the next repro tells us which gate is failing.
4. Defensive restart on `NestsListenerState.Connected`. The Closed
branch in `observeListenerState` calls `teardown(targetState =
Closed)`, which cancels `cliffDetectorJob` along with the rest of
the per-session jobs. If the cliff detector itself is what
triggered the recycle, the wrapper emits Closed → Reconnecting
→ Connected; we'd come back online with a dead detector and the
next cliff event would slip past undetected. Calling
`startCliffDetector()` (idempotent) on every Connected entry
keeps the detector alive across the cycle.
Defaults stay where they were: 4 s `ROOM_AUDIO_CLIFF_TIMEOUT_MS`,
1 s scan, 8 s cooldown — the unit tests also pin those default
constants by relying on the function's defaults in two of the cases.
Six changes that together close out the bug class the user reported
(receiver "blinks green ring for ~5 s then stops") and the related
host-side "circular spinner until first unmute" UX issue. Each is
defensible independently; together they form a defense-in-depth
against the moq-rs production-relay forward-queue cliff documented
in `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.
#3 — `NestMoqLiteBroadcaster.onLevel` only fires when
`current.send(opus)` returned `true` (frame reached an inbound
subscriber). Two-phone logs showed the host's green/glow firing for
the first ~7 s while no listener was attached and after a relay-side
cliff while no audio was on the wire. The local "I'm broadcasting"
ring now tracks the wire, not the runCatching outcome.
#5 — `DEFAULT_FRAMES_PER_GROUP: 5 → 10` (200 ms of audio per
group, 5 streams/sec). Two-phone production logs showed the relay
still cliffing at ~135 streams under sustained load with the old
5-frame default — same bug class the plan thought it had closed,
just shifted by half. Doubling the pack roughly doubles the
worst-case cliff window. Kept the existing `framesPerGroup = 5`
test scenarios intact since they're explicit on the call site.
#4 — `SUBSCRIBE_RETRY_BACKOFF_INITIAL_MS: 250 → 100`. Logs of the
join path showed 5 retries (250+500+1000+1000+1000 = 3.75 s) of
"subscribe stream FIN before reply" before the relay observed the
broadcast's announce. New ladder is 100+200+400+800+1000 = 2.5 s.
The MAX cap stays at 1000 ms so a permanently-gone publisher still
doesn't hammer the relay.
#2 — `NestViewModel.openSubscription` now passes
`maxLatencyMs = 500L` on every speaker SUBSCRIBE (new constant
`ROOM_AUDIO_MAX_LATENCY_MS`). Tells moq-rs to evict groups older
than 500 ms from the per-subscriber forward queue rather than
queuing them up to the relay's `MAX_GROUP_AGE = 30 s` default —
caps the queue growth that triggers the cliff. The wire support
existed in the listener; only the call site was passing 0L.
#1 — listener-side cliff detector. New `cliffDetectorJob` in
`NestViewModel` periodically scans `activeSubscriptions` against
`lastFrameAt` (per-pubkey monotonic TimeMark, updated in
`onSpeakerActivity`). When a speaker has been announced Active AND
we have an active subscription AND no frames have arrived for
`ROOM_AUDIO_CLIFF_TIMEOUT_MS = 4 s`, force a `recycleSession()` so
the orchestrator opens a fresh QUIC transport — a clean reset of
the relay's per-subscriber forward queue. `ROOM_AUDIO_CLIFF_COOLDOWN_MS = 8 s`
prevents back-to-back recycles while the new session is mid-handshake.
Started from `launchConnect` after `observeAnnounces`, cancelled in
`teardown`. Per-pubkey `lastFrameAt` entry dropped on
`closeSubscription` so a removed speaker doesn't trigger a stale
recycle.
#6 — auto-start broadcast pre-muted on host create.
`NestViewModel.startBroadcast` gets an `initialMuted: Boolean = false`
parameter; when `true` the broadcaster opens the publisher session,
calls `setMuted(true)` before the UI flips to `Broadcasting`, and the
mic stays open + capture loop keeps running with `if (muted) continue`
gating sends. New `LaunchedEffect(speakerPubkeyHex)` in
`OnStageIdleControls` calls `startBroadcast(initialMuted = true)`
automatically when mic permission is already granted, so a host who
just created a room sees their avatar transition to "Live (silent)"
and listeners can subscribe immediately without waiting for the host
to tap "Talk" first. Tap-to-talk path also passes `initialMuted = true`
now — unmute is a separate, explicit step on the mic toggle that
appears once we're in `Broadcasting(isMuted = true)`. Permission-
denied case still requires the manual Talk-button tap to launch the
runtime permission prompt.
Diagnostic logs from the previous two debug commits remain in place
(throttled to ≤ 1 line/sec/speaker at 50 fps capture cadence) so the
next two-phone repro can validate the fixes against logcat directly.
Audit of the four prior commits found two genuine regressions and two
robustness gaps in the new code paths.
Bug A: NestForegroundService.networkCallback dropped the publish for
the most important scenario it was added to handle. The earlier guard
`if (previous != null && previous != network)` suppressed both the
registration-time first onAvailable AND the legitimate WiFi-loss-
then-cellular-available path. On a WiFi → cellular handover the
sequence is `onLost(wifi)` (clears currentDefaultNetwork to null)
followed by `onAvailable(cellular)` — which then looks identical to
the registration callback to the guard, so no publish fires and the
QUIC session sits on the dead socket until PTO. Replaced the implicit
"previous == null" suppression with an explicit `seenInitialNetwork`
flag that's set true on the first onAvailable and never cleared, so
post-onLost reconnects publish correctly.
Bug B: requestAudioFocus result handling was too permissive. The
shape `if (result == AUDIOFOCUS_REQUEST_FAILED) TransientLoss else
Granted` falls through to Granted on the runCatching exception path
(`result == null`) and on AUDIOFOCUS_REQUEST_DELAYED (= 2) — meaning
audio plays over an active call when the OS hasn't actually released
focus. Switched to a strict `if (result == AUDIOFOCUS_REQUEST_GRANTED)`
check; everything else (FAILED, DELAYED, exception) starts the VM
muted.
Robustness: NestViewModel.openSubscription's onError callback used to
swallow every AudioException, which fit the per-packet decoder-error
case but turned PlaybackFailed and DeviceUnavailable from a deferred
beginPlayback into a permanent "Connecting…" spinner on the speaker
tile. Now we discriminate by AudioException.Kind: decoder/encoder
errors stay swallowed (Opus PLC papers them over), but PlaybackFailed
and DeviceUnavailable roll the slot back so a future reconcile can
retry.
Pre-roll ordering swap + tests: NestPlayer.play used to call
beginPlayback BEFORE flushing the pre-roll buffer, leaving a
microsecond window where the AudioTrack hardware was playing against
an empty buffer. AudioTrack MODE_STREAM explicitly supports write()
before play(), so flush-then-beginPlayback is the textbook pattern
and what the fix now does. Three regression tests cover:
- pre-roll defers beginPlayback until threshold is met (and the
flush-then-begin ordering is observable)
- partial pre-roll flushes when the upstream flow ends early
- empty flow doesn't begin playback at all
The FakeAudioPlayer grows beginPlaybackCount + queuedAtBeginPlayback
fields so the tests can assert ordering directly.
Four follow-up fixes from the post-audit review (#4 / #5 / #6 / #7).
#6 AcousticEchoCanceler / NoiseSuppressor / AGC on the AudioRecord
session. The VOICE_COMMUNICATION input source engages the platform
echo canceller automatically on most modern Android devices, but a
small set of older / OEM-customised devices only attach AEC under
MODE_IN_COMMUNICATION — which an audio-room app deliberately
avoids. Attaching the standalone audiofx effects to the
AudioRecord's session id covers those devices without rerouting
through the call audio path. All three are best-effort and a no-op
on devices where the source already engages them.
#4 Real audio focus handling. The previous OnAudioFocusChangeListener
was a no-op based on the assumption that the OS would auto-duck
us; it doesn't (CONTENT_TYPE_SPEECH streams aren't auto-ducked).
Inbound phone calls were mixing on top of room audio.
- New `NestAudioFocusBus` (commons) — process-wide enum signal,
decoupled from android.media so commons stays platform-free.
- NestForegroundService translates AUDIOFOCUS_GAIN / LOSS_TRANSIENT*
/ LOSS into the bus enum.
- NestViewModel observes the bus and silences both the listener
playback (effective listen-mute = user OR focus) and the
broadcast mic (effective mic-mute = user OR focus). User-visible
mute states stay the user's choice so a focus regain restores
them automatically.
- The pipeline keeps running while focus is lost (decoder, capture,
network) so unmute is sample-accurate when the call ends.
#5 AudioDeviceCallback observability. Registers a callback in
NestForegroundService that logs Bluetooth / wired / USB headset
attach + detach with device type + name. Doesn't drive playback
decisions — Android's auto-routing handles route swaps — but
makes "audio cut out when I plugged in headphones" reports
correlatable with a concrete event for the first time.
#7 Network-change → fast reconnect. Without this, a Wi-Fi → cellular
handover left the QUIC connection sitting on a now-dead socket
until its PTO fired (~30 s) before the wrapper noticed. Now:
- New `NestNetworkChangeBus` (commons) — collapses bursts of
onLost/onAvailable into a single recycle event.
- NestsListener + NestsSpeaker grow `recycleSession()` (default
no-op); the reconnecting wrappers override to close the inner
session so their orchestrator opens a fresh one.
- NestForegroundService registers a default-network callback;
suppresses the first onAvailable (registration callback)
and only publishes on actual default-network changes.
- NestViewModel observes the bus and calls recycleSession on
both wrappers. The SubscribeHandle re-issuance pump (listener)
and the hot-swap publisher pump (speaker) cut existing
subscriptions / broadcasts onto the new session as soon as
it lands — same paths the JWT-refresh recycle uses.
- Manifest gains ACCESS_NETWORK_STATE for
registerDefaultNetworkCallback.
1. Listener-side pre-roll + bigger playback buffer + audio-priority thread.
- NestPlayer buffers 5 decoded frames (~100 ms) before starting
the AudioPlayer, masking the first-frame underrun that fires
whenever Compose / GC briefly stalls Main.
- AudioTrackPlayer sizes the AudioTrack at max(minBuffer*16, 250 ms)
instead of minBuffer*4 (~80 ms) and writes via a per-instance
audio-priority single-thread executor (Process.THREAD_PRIORITY_AUDIO)
instead of Dispatchers.IO, so WRITE_BLOCKING never contends with
unrelated IO work.
2. MoqLiteSession.subscribe: hoist response typeCode out of the
collect lambda. readVarint advances pos permanently while
readSizePrefixed only rolls back its own length-varint, so a
chunk-split between type and body would re-read the body's size
prefix as the type code on the next chunk and misframe the
response. Mirrors the same fix already in handleInboundBidi.
3. MoqLiteSession.subscribe: register the subscription in the map
BEFORE writing the SUBSCRIBE bytes on the wire. The relay can
open the first group's uni stream before our continuation
re-enters [state] to register; if so, drainOneGroup looked the
id up against an empty map and silently dropped the frame
(~1 frame / 20 ms gap on first attach). Wrap the post-register
writes so a transport-failure unwinds the orphan registration.
4. Hot-swap moq-lite publisher across JWT-refresh boundaries.
- NestMoqLiteBroadcaster: publisher is now @Volatile + supports
swapPublisher(); capture loop snapshots the reference per frame
and resets framesInCurrentGroup on swap.
- MoqLiteNestsSpeaker implements a new internal
HotSwappablePublisherSource interface that exposes
openPublisherForHotSwap(track) without spinning up a broadcaster.
- ReissuingBroadcastHandle keeps a single long-lived broadcaster
across session recycles when the speaker supports hot swap;
legacy IETF / fake speakers fall back to close-then-restart.
- connectReconnectingNestsSpeaker.orchestrator hoists the old-
session close onto a sibling launch so the wrapper can swap
the publisher into the broadcaster on the new session before
the old session's WebTransport drops. Eliminates the previously-
accepted 50–150 ms audible silence at every JWT refresh.
Migrates 32 call sites from KeyDataSourceSubscription to
LifecycleAwareKeyDataSourceSubscription so feed/screen REQs are
paused when the app goes to background and resumed on foreground,
saving relay bandwidth while the always-on notification service
keeps the socket open.
Adds a MutableComposeSubscriptionManager overload to
LifecycleAwareKeyDataSourceSubscription so the search bars (which
bind to a flow-driven query) can also opt in.
Skips AccountFilterAssemblerSubscription (always-on account state)
and NWCFinderFilterAssemblerSubscription (in-flight zap payments
that must complete in the background).
https://claude.ai/code/session_01RUmRxmzUAcVXezF9PEETGz
Tearing down a relay REQ on every ON_STOP and rebuilding it on ON_START
churns EOSE state and triggers a refetch when the user briefly switches
to another app. Schedule the unsubscribe 30s after ON_STOP and cancel it
on ON_START so short app switches keep the subscription alive.
https://claude.ai/code/session_01W3RY9Rf4gc4eEkL4v1v8Bg
Previously the catch-block warnings interpolated ${e.message} but
dropped the throwable, losing the stack trace and showing nothing for
exceptions whose message is null. Switch to the (tag, msg, throwable)
overload so the cause and stack trace are logged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avoid eager string interpolation when the log level is filtered out at
runtime. Covers Log.d/i calls and Log.w/e calls that did not pass a
throwable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merge upstream main into feat/desktop-multi-account, resolving:
- Main.kt: Surface wrapper + CompositionLocalProvider + nip11Fetcher from upstream, multi-account DeckSidebar/LoginScreen from HEAD
- FeedScreen.kt: viewport-aware scroll from HEAD + contentPadding from upstream
- DeckSidebar.kt: merged imports (multi-account + titleBarInsetTop + MaterialSymbols)
- Migrated AccountSwitcherDropdown + AddAccountDialog from material.icons to MaterialSymbols
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire the local user's avatar into the existing speaking-ring animation so
the host/speaker sees the same green ring + amplitude glow on their own
cell that remote speakers already get.
Ground truth is the broadcaster's `publisher.send(opus)` success: the new
`onLevel` callback fires only after a frame actually leaves on the wire,
computed from the raw PCM peak (shared `peakAmplitude` util used by both
the player decode loop and the broadcaster capture loop). This means the
animation reflects what the relay sees, not any UI-side mute / role
state that could be stale or buggy — if frames are flowing, the ring
plays.
Plumbing: `NestsSpeaker.startBroadcasting` gains an optional `onLevel`
that the moq-lite + IETF broadcasters both honour, the reconnecting
wrapper replays it on every session reissue (alongside the existing
mute-intent replay), and `NestViewModel.startBroadcast` hands it a
lambda that calls the existing `onSpeakerActivity` / `onAudioLevel`
plumbing keyed on the local pubkey. The 250 ms speaking-timeout fades
the ring naturally when the user mutes or stops broadcasting.
The host-leave confirmation dialog's Close the Room button only called
finish(), relying on VM.onCleared() to release the AudioRecord. That
runs late in the destroy lifecycle, so the system mic-in-use indicator
stayed lit while the activity was queued for destruction.
Adds NestViewModel.leave(), which mirrors onCleared() (sets closed,
runs both teardowns with finalCleanup=true so closes route through
cleanupScope/GlobalScope and survive Activity destruction). Wires it
into the Close the Room callback only — the Just Leave and non-host
Leave paths are unchanged per request.
Audit-driven fixes for the audio-room reactions overlay:
1. RoomReactionsAggregator now dedups by event id. The previous code
appended every event to a flat list, but LocalCache.observeEvents
re-emits the full matching list on every cache mutation — so an
N-reaction window grew quadratically and the overlay rendered the
same emoji once per replay. Keyed by event id collapses re-emits.
2. RoomPresenceAggregator gains applyOrNull that returns null when an
incoming heartbeat is older-or-equal to the cached presence; the VM
skips the StateFlow write in that case. In a 200-peer room, every
replay used to copy a 200-entry map plus run an O(N) StateFlow
equality check 200 times per emission. Now it's one-and-skip.
3. ReactionsEvictionTicker only ticks while the aggregator is non-empty.
Once the last reaction expires the loop self-cancels until the next
reaction lands — a quiet room costs no scheduled work.
4. REACTION_WINDOW_SEC dropped 30s -> 10s. Reactions are about what
the speaker is saying right now; a 10s window keeps the overlay
timely instead of bleeding into the next paragraph.
5. SpeakerReactionOverlay drives a per-chip lifecycle animation: each
chip drifts upward ~16dp and fades over the 10s window. A fresh
reaction (same emoji) restarts the chip's animation. The
AnimatedVisibility outer entry/exit still smooths first-arrival and
final disappearance.
Tests: added a re-emit dedup case to RoomReactionsStateTest plus an
end-to-end replay assertion in NestViewModelTest. Existing tests
updated to use unique event ids.
https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
Presence entries are valid for ~10 min (PRESENCE_FRESHNESS_WINDOW_SECONDS in
NestsFeedFilter). Without explicit cleanup, presenceNotes would grow unbounded:
every author who ever heartbeats in a room leaves an entry there forever.
The freshness check kept the filter result correct, but memory only ever grew.
Add LiveActivitiesChannel.pruneStalePresence(cutoff) and call it from
LocalCache.pruneOldMessagesChannel alongside the existing notes prune. Cutoff is
2x the freshness window (20 min) so a presence still inside any feed's window
can never be reaped.
Presence (kind-10312) was being stored in both `channel.notes` and the
`presenceNotes` index. The mixed-kind `notes` map is dominated by chat
in active rooms, and only HomeLiveFilter still read presence from it --
which is now migrated to scan presenceNotes directly.
- LocalCache.consume(MeetingRoomPresenceEvent): drop the `channel.addNote`
call; only addPresenceNote, plus addRelay so the channel's relay-counter
still tracks where presence arrived from.
- LiveActivitiesChannel: addPresenceNote / removePresenceNote emit on
flowSet.notes so reactive observers (NestsFeedLoaded) still update.
- HomeLiveFilter.shouldIncludeChannel: scan presenceNotes separately for
follow-broadcast detection in audio rooms (chat scan unchanged).
- HomeLiveFilter.followsThatParticipateOn: also count presenceNotes
authors so audio-room hosts/speakers factor into the participation
sort even when they haven't chatted.
- ChannelFeedFilter: delete the isChatEvent workaround that was excluding
presence from the chat feed -- presence no longer lands there.
kind-10312 is replaceable per author, but the room a presence points to
(`a`-tag) can change when a speaker hops between rooms. The replaceable
cache only swaps the addressable's content -- it doesn't know which channel
the old version was attached to, so the prior room kept the stale entry in
both `notes` and the new `presenceNotes` index. NestsFeedFilter would then
falsely surface the prior room as "live" via that author until the entry
dropped out of the freshness window.
Capture the prior room from the existing addressable before consumeBaseReplaceable
swaps it. When the new event is a true replacement (createdAt > prior) and
the room differs, drop the author from the prior channel's presenceNotes
and remove the prior version note from its main notes index.
LiveActivitiesChannel.notes is mixed-kind (chat, zaps, raids, clips, presence),
and chat dominates by volume in active rooms. The Nests feed filter scanned it
twice per room per recompute -- once for any-fresh-presence, once for fresh
on-stage presence -- doing an `is MeetingRoomPresenceEvent` cast on every chat
message just to find the speakers.
Add a presenceNotes index on LiveActivitiesChannel keyed by author pubkey
(presence is replaceable per author, so the key auto-collapses heartbeats).
Populate it from LocalCache.consume(MeetingRoomPresenceEvent). Switch
NestsFeedFilter to a single hasFreshSpeakers() pass over the index, dropping
the now-redundant isLiveByPresence() check (hasFreshSpeakers implies it).
Migrate NestsFeedLoaded's latest-presence flow to the same index.
Three fixes:
1. Display names now stored in AccountInfo/AccountInfoDto and persisted
in encrypted storage. Populated when metadata arrives from relays via
metadataVersion LaunchedEffect. Available immediately on next open.
2. loadInternalAccount falls back to loadReadOnlyAccount when no private
key found in SecureKeyStorage — fixes npub-only accounts that were
saved as SignerType.Internal from earlier sessions.
3. Dropdown uses persisted displayName first, cache lookup as fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
(ensureCurrentAccountInStorage called in onLoginSuccess)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three improvements stacked into one commit since they share files:
#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
stage. The "Stage" label stays and a quiet "Waiting for speakers…"
line keeps the strip visible so the room doesn't look broken before
the first speaker arrives.
#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
default-no-op method; AudioTrackPlayer composes mute and volume
multiplicatively into AudioTrack.setVolume so a hushed stream stays
silent regardless of mute state. NestViewModel exposes
locallyHushed: ImmutableSet<String> in NestUiState plus
setLocalHushed(pubkey, hushed) — applied at attach time so a
re-subscribe of an already-hushed speaker stays silent. New "Hush
this speaker" / "Restore this speaker" row in
ParticipantHostActionsSheet, available to anyone (it affects only
our own playback, nothing on the wire).
#4 Host moderation gaps: wires Promote-to-Moderator using the existing
RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
variant + AdminCommandEvent.forceMute(room, target) builder; the
sheet emits it on "Force-mute speaker" and the
AdminCommandsCollector dispatches incoming MUTE actions to a new
NestViewModel.onForceMuted() that routes through the existing
setMicMuted(true) path. Honor-based, same trust model as KICK —
relays don't enforce signer authority, the client checks the
signer is host or moderator on the active kind-30312.
The previous emitter ran a permanent while(true){delay} loop on the
viewModelScope from connect-time onward. Cheap in production but
catastrophic for any test that calls runTest's advanceUntilIdle()
after vm.connect() — virtual time never reached idle, hanging
NestViewModelTest's existing connection-state cases for ~15 minutes
before the worker timeout killed them.
Now the emitter is started lazily by onAudioLevel() and exits the
loop the first tick after rawAudioLevels empties, so an idle room
schedules nothing. The next decoded frame restarts it. Idempotent,
so the launch path doesn't need to call it any more — removed the
boot from launchConnect().
Adds an end-to-end audio-amplitude pipeline so on-stage speakers'
green ring throbs in time with their voice (closer to Spaces /
Clubhouse than the previous binary "is in speakingNow" indicator).
NestPlayer.play() now takes an onLevel callback and computes the
normalized peak of each decoded 16-bit PCM frame. NestViewModel
exposes audioLevels: StateFlow<Map<String, Float>>; raw 50 Hz
updates from the decode loop are coalesced into a 10 Hz publish
tick (LEVEL_TICK_MS) so the StateFlow doesn't spam recompositions
across a busy stage. The map is cleared on speaker close, on the
speaking-timeout sweep, and on teardown.
In MemberCell the speaking ring's width animates between
AVATAR_RING_WIDTH (3 dp) and MAX_RING_WIDTH (7 dp) via
animateDpAsState, smoothing the tick into a continuous halo.
Muted-publisher and idle states are unchanged.
Adds NestPlayerTest coverage for the new callback (level math +
empty-PCM short-circuit).
Mirror the listener's ReconnectingNestsListener for the publish side.
moq-auth issues 600 s bearer tokens; without proactive refresh, a user
holding the stage past 10 minutes silently drops when the relay tears
down the WebTransport session and stays off the air until they manually
re-tap Talk. The new wrapper recycles the session at 540 s so the relay
never sees an expired token, and re-issues publishing onto each fresh
session with the user's mute intent replayed on the new handle.
VM swap is a one-line change to DefaultNestsSpeakerConnector. The
caller-owned BroadcastHandle is now the wrapper's stable handle that
survives every refresh.
Six unit tests cover happy path, refresh-without-failure-state, mute
replay across recycle, close idempotence, first-attempt-failure
exception propagation, and post-close startBroadcasting guard.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
EGG-10 lets each `["c", hex, role]` tag stand alone, but a half-
applied palette (themed background + platform text, or themed text
+ platform background) collides with whichever system theme — light
or dark — the user is on. A room that ships ONLY `background=#FFE4B5`
ends up with platform-default white text on dark theme; unreadable.
Gate all three color overrides on having a complete bg + text pair
inside `RoomTheme.from(event)`. When either is missing the whole
palette drops to null and renderers fall through to the platform
theme. Background image (`bg`) and font tags still flow through —
they don't break contrast on their own.
Both consumers — NestThemedScope (room screen) and NestJoinCard
(lobby) — pick up the change without code edits because they read
through the same `RoomTheme` projection.
The nostrnests-style `["color", "gradient-7"]` tag was never parsed
(our `c`-tag parser ignores it), so events shipping only that don't
override anything either way; this fix targets the genuine half-
applied case (rooms that emit one EGG-10 c-tag without the matching
companion).
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
Four related state-leak bugs surfaced by the new-interface audit:
1. fetchSpeakerCatalog launched a fire-and-forget coroutine that
wasn't tracked per-pubkey. With the wrapper's re-issuing
handle the catalog subscription survives session swaps —
and a removed speaker's collector kept re-adding the
catalog map entry on every emission. Fix: per-pubkey job map
(catalogJobs); cancel on closeSubscription before dropping
the map entry.
2. teardown() cleared activeSpeakers / speakingNow / announces
but not _speakerCatalogs. Stale catalog data accreted
across reconnects + room swaps. Fix: cancel every catalog
job and reset the map alongside _announcedSpeakers.
3. teardown()'s _uiState.copy(...) reset activeSpeakers and
speakingNow but not connectingSpeakers. The pre-roll spinner
could persist on stale pubkeys after a disconnect. Fix:
include connectingSpeakers in the same copy.
4. fetchSpeakerCatalog ran BEFORE the abandoned-subscription
re-check in openSubscription, so a removed speaker's catalog
subscription opened anyway. Fix: move the catalog kick-off
to after the re-check + after slot.attach (the catalog needs
a confirmed-attached audio slot to be cancellable via
closeSubscription).
Surface moq-lite's ANNOUNCE flow on NestsListener so the audio
room can render an authoritative "actively broadcasting"
indicator independent of kind-10312 presence's `publishing`
flag. Same channel nostrnests' web client uses for its live
badges (`useRoomAnnouncements` in the JS reference).
Wire-up:
* RoomAnnouncement(pubkey, active) data class — one update
per publisher transition (Active → broadcast came up,
inactive → broadcast went down).
* NestsListener.announces(): Flow<RoomAnnouncement> with a
default body that throws UnsupportedOperationException on
the IETF reference path.
* MoqLiteNestsListener implements via session.announce("")
against the room's namespace; the suffix carries the
speaker pubkey hex straight through.
* ReconnectingNestsListener routes via collectLatest so the
consumer-facing flow restarts against each new session
after a refresh / reconnect (no SubscribeHandle re-issuance
pump needed — announces is a cold per-collect stream).
* AudioRoomViewModel.announcedSpeakers: StateFlow<Set<String>>
populated by observeAnnounces. Active emissions add the
pubkey, inactive emissions remove it. Cleared on teardown.
IETF listeners leave the set empty; UI falls back to
presence's publishingNow flag.
Tests:
* NestsListenerCatalogTest — adds the announces() default-
throws-on-collect case.
Closes the audit's Tier-4 #17b gap. UI integration (e.g. a green
"live" dot driven by announcedSpeakers) is a follow-up — the data
flow is in place and downstream consumers can opt in.
Tracks the window between SUBSCRIBE_OK and the first decoded audio
frame (typically 0.5-2s on a fresh subscription) so the user can
see audio is on its way rather than wonder why a "live" speaker
is silent.
Wire-up:
* AudioRoomUiState gains `connectingSpeakers: ImmutableSet<String>`,
a set-once-per-subscription field.
* VM enters the set on `slot.attach(...)` (right after SUBSCRIBE_OK
succeeds), exits on the first `onSpeakerActivity` callback (first
decoded packet), and clears on speaker removal. Set membership
survives subsequent silence — once a speaker has delivered a
frame, we know the pipeline works.
* ParticipantsGrid renders a small CircularProgressIndicator
overlaid on the avatar (sized smaller than the picture so the
user stays recognisable underneath).
Audience-side avatars don't get the overlay — they have no MoQ
subscription. Only on-stage speakers in the connectingSpeakers set
show it.
The catalog subscription API shipped two commits ago but nothing
read it. This wires it up:
* RoomSpeakerCatalog data class (commons) — kotlinx.serialization
parser for moq-lite's `catalog.json` payload (version, audio
track list with codec / sample_rate / channel_count / bitrate).
Forward-compat: ignoreUnknownKeys + nullable fields tolerate
new keys and partial publishers.
* AudioRoomViewModel.speakerCatalogs: StateFlow<Map<String,
RoomSpeakerCatalog>> populated lazily as per-speaker
subscriptions land. Catalog fetch piggybacks on openSubscription
via subscribeCatalog — best-effort, doesn't gate audio.
* Participant context sheet renders a single-line summary
("OPUS · 48kHz · 2ch") below the pubkey when the catalog is
available.
* Enabled kotlinx.serialization plugin on :commons (was already
a transitive dep, just not wired for @Serializable codegen).
Tests:
* RoomSpeakerCatalogTest — 7 cases covering the canonical shape,
describe() formatting (codec uppercased, kHz / channel
short-formed), all-null fallback, unknown-key tolerance,
garbage-bytes fallback, and empty-audio-list.
Switch the production NestsListenerConnector default to wrap each
session in connectReconnectingNestsListener, then retire the VM's
own scheduleAutoRetry / autoRetryAttempts / connectInternal /
retryPending state machine.
A single retry policy now lives in the transport layer (the
wrapper's NestsReconnectPolicy) rather than racing two of them —
the VM's previous retry would tear down + recreate subscriptions
on every attempt, dropping audio. With the wrapper:
* transport drops auto-retry with exponential backoff,
* existing SubscribeHandle objects keep emitting via the
MutableSharedFlow re-issuance pump (already shipped in T4 #2's
earlier commit),
* the existing speaker set survives through Reconnecting →
Connected without per-attempt re-subscription.
UI side: new ConnectionUiState.Reconnecting(attempt, delayMs)
surfaces the wait. AudioRoomFullScreen shows a "Reconnecting…"
chip during the transient window — the user typically doesn't
need to act, the wrapper recovers on its own.
Existing AudioRoomViewModelTest covers connect/disconnect/teardown
unchanged; ReconnectingNestsListenerTest in :nestsClient owns the
retry-policy contract.
Closes the deferred font tag from the Tier-3 plan. Wire-up:
* quartz: FontTag(family, optionalUrl) parser/assembler with
blank-rejection on family + blank-URL-becomes-null normalisation.
* MeetingSpaceEvent.font(): FontTag? accessor.
* TagArrayBuilder.font(family, url) DSL.
* RoomTheme gains fontFamily / fontUrl fields.
* AudioRoomThemedScope maps `family` to a Compose FontFamily for
the four CSS-style generic-family names ("sans-serif", "serif",
"monospace", "cursive") — each maps to the corresponding system
fallback. The whole Material3 typography is rebuilt with that
family so headlines / body / labels all swap together.
URL-based font loading (RoomTheme.fontUrl) is the natural follow-up:
fetch + cache via OkHttp, then build a FontFamily from a local
file. Until then, an unknown family is silently a no-op — the room
renders in the platform default rather than crashing or fetching
on the UI thread.
Tests:
* FontTagTest — 9 cases covering family-only, family+URL,
blank rejection, missing family, wrong tag name, blank-URL
normalisation, assembler shapes (with + without URL), and
the assembler's blank-family rejection.
* RoomThemeTest gains 3 cases for font projection (family-only /
family+URL / empty event).
Lays the foundation for the moq-lite reconnect-with-backoff path:
NestsReconnectPolicy — exponential backoff settings.
(initial=1s, multiplier=2, max=30s) by
default, mirroring kixelated/moq's JS
reference (`delay: { initial: 1000,
multiplier: 2, max: 30000 }`).
maxAttempts defaults to Int.MAX_VALUE
since a long-running room should keep
trying as long as the user hasn't left;
the Composable's onDispose is the cancel
signal.
NoRetry sentinel for first-shot-or-fail
tests / single-room demos.
delayForAttempt(n) — 1-indexed; doubles per attempt; clamps at
maxDelayMs. n<1 returns 0.
isExhausted(n) — n >= maxAttempts (next retry forbidden).
init { require(...) } — ctor guards reject zero/negative initial,
multiplier <= 1, max < initial, attempts < 1.
NestsListenerState.Reconnecting / NestsSpeakerState.Reconnecting
— new state variants with (attempt, delayMs). UI consumes via
the existing NestsListenerState → ConnectionUiState mapper which
surfaces Reconnecting under OpeningTransport for the v1 chip;
a future commit can add a dedicated "Attempt N in Mms" UI.
Tests:
* First attempt = initialDelayMs
* Doubling via attempt index until maxDelayMs ceiling
* Non-standard multiplier still respects ceiling
* Zero/negative attempt → 0 delay
* isExhausted at the boundary
* NoRetry exhausts after attempt 1
* Constructor rejects invalid inputs
The orchestration layer (mint-fresh-JWT → reopen WT → re-issue
SubscribeHandles + the speaker-side equivalent) is the heavier
follow-up. Deliberately split because:
1. The state + policy can ship and be consumed by callers ready
to handle Reconnecting today — no behaviour change for those
who don't.
2. The full session-resurrection path needs `MutableSharedFlow`
buffering per SubscribeHandle so app code's `Flow<MoqObject>`
doesn't notice the swap. Substantial refactor; better as its
own commit with its own tests.
Materialised view of the kind-30312 theme tags into a small
renderer-friendly struct. Pure data + a `from(event)` projection
function — the Compose `AudioRoomThemedScope` wrapper consumes it
later.
RoomTheme — packs colors as `0xAARRGGBB` Long (full alpha)
so commons stays free of the Android Color type;
the Compose renderer recovers via `Color(argb)`.
`null` per field means "use the platform default" so
partial themes (background-only, primary-only, etc.)
fall back per-field.
RoomTheme.Empty — sentinel for un-themed rooms; renderer can pass
it unconditionally without an extra null branch.
RoomTheme.from(event) — picks the FIRST color per target (palette
fallbacks deferred to a later phase),
drops typo'd hex via Quartz's strict
ColorTag parser (returns null per field
rather than crashing), maps the
BackgroundTag mode to the renderer enum
(unknown wire modes fall back to COVER).
Tests:
* Empty event → empty theme
* Three color targets project to opaque ARGB Longs
* First-per-target wins (extra colors ignored in v1)
* Typo'd hex leaves null per field; OTHER colors still project
* Background URL + tile mode round-trip
* Unknown bg mode (a future "blur") → COVER fallback
* hexToOpaqueArgb always sets alpha=0xFF (no inadvertent
transparency for #000000)
Compose renderer + AudioRoomThemedScope wrapper come next.
Data model + projection function for the participant grid:
RoomMember — one row in the grid. Combines the static `p`-tag
role (kind-30312) with the dynamic presence flags (kind-10312
aggregator). Carries an `absent` Boolean for "member never
joined" — nostrnests greys them out and we keep parity by
surfacing the flag for the UI to decide.
ParticipantGrid — onStage / audience split.
buildParticipantGrid(participants, presences) — pure projection.
Stage placement is `canSpeak() && onstage != false`, so a speaker
who explicitly emits `onstage=0` (Tier 1 Step 1's "step off the
stage" tag) drops to audience without losing their speaker role.
Pure-audience members (present in the ledger but not p-tagged)
show up in audience with `role = null`.
Tests:
* Host + speaker on stage with matching presence
* Speaker with onstage=0 drops to audience
* Pure listener (no p-tag) lands in audience with role=null
* P-tagged speaker without presence is on stage with absent=true
* Empty inputs produce an empty grid (no crash)
The actual `LazyVerticalGrid` rendering is a follow-up — current
`StagePeopleRow`s already cover the on-stage/audience split
visually; the data model is the substantive piece. Wiring the
VM's `participantGrid: StateFlow<ParticipantGrid>` and switching
the room screen to a grid layout can ship later without changing
the wire contract or tests.
End-to-end glue for the kick action and the broader per-participant
management surface:
AudioRoomViewModel.wasKicked: StateFlow<Boolean>
AudioRoomViewModel.onKick() — set-once flag, calls disconnect().
Idempotent. Authority enforcement (signer must be host/moderator)
is the platform layer's job — the relay does not enforce it.
RoomAdminCommandsFilterAssembler — REQs `kinds=[4312], #a=[room],
#p=[localPubkey]` so the relay only forwards commands actually
targeting the local user.
AudioRoomActivityContent — opens the wire sub on enter, observes
LocalCache for new AdminCommandEvents, and gates each kick on the
signer being either the room's pubkey OR a participant whose
current role is host/moderator. Calls vm.onKick() on a valid
match, then onLeave() once wasKicked flips.
ParticipantHostActionsSheet — bottom sheet with three rows:
Promote to speaker / Demote to listener / Kick. The destructive
Kick row is colored error. Promote + Demote use
RoomParticipantActions; Kick uses AdminCommandEvent.kick.
StagePeopleRow + AudioRoomFullScreen — long-press an avatar
(host-only, can't long-press the host themselves) opens the
ParticipantHostActionsSheet for that target.
RelaySubscriptionsCoordinator.roomAdminCommands registered
alongside the other audio-room subs.
Strings: audio_room_promote_speaker, audio_room_demote_listener,
audio_room_kick_action.
Tests:
* onKickFlipsWasKickedAndDisconnects — VM state transition
* onKickIsIdempotent — no double-disconnect
Adds the listener-side fan-in for the speaker-avatar reaction
overlay (T1 #3):
recentReactions: StateFlow<Map<String, List<RoomReaction>>>
onReactionEvent(event, nowSec, windowSec = 30)
evictReactions(olderThanSec)
The platform layer is the source — amethyst observes LocalCache for
kind=7 with #a=[roomATag] and pipes events here. The 30-s window
constant lives next to SPEAKING_TIMEOUT_MS so the staleness
configuration is one place. Caller-driven tick (no internal timer
inside the VM) keeps the lifecycle aligned with the Composable.
Test:
* onReactionEventGroupsByTargetAndEvictsOnTick — two reactions on
bob arrive within the window; tick advances past the window;
overlay clears.
Data + dedup logic for the speaker-avatar reaction overlay (T1 #3):
RoomReaction — one ephemeral kind-7 reaction. Carries the
source pubkey, the target pubkey (null for
room-wide), the emoji/content, and the
createdAt second. Standard data-class equality
so the StateFlow can suppress no-op tick
re-emits via map.equals.
RoomReactionsAggregator
— `apply(event, nowSec, windowSec)` records a
fresh reaction and returns the post-evict
Map<targetPubkey, List<RoomReaction>>.
`evictAndSnapshot(olderThanSec)` is the
per-tick sweep — caller drives the cadence
(typically every second so the floating-up
animation frame rate is set by the eviction
tick rather than per-component timers).
Room-wide reactions (no `p` tag) land under
the empty-string key so the value-type stays
uniform.
Tests cover:
* Tag projection (with + without `p` target)
* Multi-source grouping by target speaker
* Window-edge eviction (a reaction at T=70 is gone at now=110,
window=30; a reaction at T=105 stays)
* Empty-string key for room-wide reactions
* Idempotent eviction snapshot — same input twice produces equal
Maps so the UI doesn't recompose on no-op ticks
Zap reactions (kind 9735) carry an amount + zapper that aren't in
the v1 RoomReaction shape; that's a follow-up if/when the UI grows
a "satoshi rain" treatment. The ledger stays generic on `content` so
adding it later is additive.
Adds the listener-side state for the live-chat panel (T1 #2):
AudioRoomViewModel.chat: StateFlow<List<LiveActivitiesChatMessageEvent>>
AudioRoomViewModel.onChatEvent(event)
Same precedent as `presences` — the platform layer is the source
(amethyst observes LocalCache for kind=1311 + #a=[roomATag] and
pipes events here). The list is `created_at`-ascending so the chat
panel auto-scrolls newest at the bottom. Dedupes by event id so a
relay re-emit on reconnect doesn't double up the transcript.
Tests:
* onChatEventAccumulatesMessagesSortedByCreatedAt — out-of-order
arrivals end up in the right place on screen
* onChatEventDedupesByEventId — same event from two relays /
one reconnect produces ONE row
The amethyst-side wire sub + chat panel UI come next.
Adds the listener-side fan-in API the participant grid + listener
counter consume:
AudioRoomViewModel.presences: StateFlow<Map<String, RoomPresence>>
AudioRoomViewModel.onPresenceEvent(MeetingRoomPresenceEvent)
AudioRoomViewModel.evictStalePresences(olderThanSec: Long)
The platform layer (amethyst, next commit) observes LocalCache for
`kinds=[10312], #a=[roomATag]` and pipes events through onPresenceEvent;
that keeps the data source platform-specific and lets commons stay free
of Android-only LocalCache references. evictStalePresences runs on a
periodic tick driven by the platform layer.
Bug-fix while wiring this: the initial RoomPresence had a custom
pubkey-only equals() (intended to make Set<RoomPresence> dedup
straightforwardly). That broke Map<String, RoomPresence>.equals on a
heartbeat-only update — old.equals(new) returned true even when
handRaised / publishing flipped, so StateFlow suppressed the emission
and the UI never saw the change. Reverted to the data-class
all-fields equals; the Map is keyed by the pubkey String anyway, so
the custom equals never bought us anything. Test
`roomPresenceEqualityIsAllFields` pins the contract so it can't drift
back.
Tests:
* onPresenceEventPopulatesPresencesMapAndDedupesByPubkey
* evictStalePresencesDropsOldPeers
Introduces the data model + in-memory dedup/eviction logic the
participant grid (Tier 2 #1), hand-raise queue (T1 #5), and listener
counter (T1 #8) all consume.
RoomPresence — one peer's most recent kind-10312 snapshot.
Equality is by pubkey alone so a Set or Map
swap on heartbeat update doesn't double-count
when only the timestamp changes.
`RoomPresence.from(event)` projects every
presence tag, with conservative defaults for
absent ones (handRaised/publishing default
false; muted stays null to distinguish "didn't
say" from "explicitly false"; onstage defaults
TRUE so pre-onstage clients still render as
speakers).
RoomPresenceAggregator
— `apply(event)` dedupes by pubkey, keeps the
most recent createdAt (out-of-order events
can't overwrite newer state). `evictOlderThan`
is the staleness sweep — caller drives the
cadence (typically a 60 s tick passing
`now - 6*60` to evict on >6 min of silence).
Tests cover: tag projection, the absent-tag defaults, dedup on same
pubkey, out-of-order non-overwrite, multi-pubkey isolation, eviction
window, and the pubkey-only equality contract.
The amethyst-side LocalCache wiring + listener-counter UI come next
in their own commit.
Threads the new kind-10312 presence dimensions through the VM and the
heartbeat:
* AudioRoomUiState.onStageNow: explicit Boolean field, defaults to
true. Tier-2's "leave the stage" tap will flip it to false via
AudioRoomViewModel.setOnStage(...). Drives the
`["onstage", "0|1"]` tag.
* AudioRoomUiState.publishingNow: derived property, true only when
broadcast is Broadcasting AND not muted. Drives the
`["publishing", "0|1"]` tag. Matches the wire-tag semantics
"actually pushing audio packets" (vs holding a slot but silenced).
The heartbeat in AudioRoomActivityContent now reads both values and
passes them to MeetingRoomPresenceEvent.build. onstageTag IS a
LaunchedEffect key (so leaving the stage triggers an immediate
publish), publishingTag is NOT (mute toggles already publish via the
debounced effect).
The leave / dispose path now publishes publishing=false + onstage=false
explicitly, so aggregating peers can drop us from the grid immediately
instead of waiting out the staleness window.
Tests:
* AudioRoomViewModelTest::onStageNowDefaultsTrueAndSetOnStageFlipsIt
* AudioRoomViewModelTest::publishingNowDerivesFromBroadcastStateAndMute
(covers all four BroadcastUiState states + the muted-broadcasting
edge case where publishingNow must be false despite holding a slot)
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.