f922cfd5bcb1ce8a41663db2f8d754f41dc1856f
561 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
003cf42564 |
fix(nests): self-audit pass — two real bugs + robustness + tests
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.
|
||
|
|
6237c02c6f |
fix(nests): platform-side audio robustness — focus, AEC, route obs, network handover
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. |
||
|
|
076b301d84 |
fix(nests): close 4 audio-dropout sources across listener + speaker
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.
|
||
|
|
98c0438526 |
feat(subscriptions): make screen subscriptions lifecycle-aware
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 |
||
|
|
e6eca9362a |
feat(commons): delay lifecycle-aware unsubscribe by 30s
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 |
||
|
|
b1fb13cddc |
Merge pull request #2720 from davotoula/feat/log-lambda-overloads
chore: convert interpolated Log calls to lambda overload + restore throwables in Marmot catch blocks |
||
|
|
31dee7fe38 |
fix(log): pass throwable to Log.w in Marmot catch blocks
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>
|
||
|
|
a22f63e42c |
refactor(log): convert interpolated Log calls to lambda overload
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> |
||
|
|
fd34105439 |
merge: resolve conflicts with upstream/main
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> |
||
|
|
5d955eff99 |
feat: animate local speaker ring from MoQ frame transmission
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. |
||
|
|
36152243ab |
fix: tear down nest session on host Close the Room
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. |
||
|
|
3acd84bf76 |
fix(nests): reactions dedup, drift-fade animation, 10s window
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 |
||
|
|
612f2aa31e |
fix(nests): prune stale presence entries from LiveActivitiesChannel
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. |
||
|
|
566133750b |
refactor(nests): keep room presence out of channel.notes entirely
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. |
||
|
|
7845072f20 |
fix(nests): evict author from prior room when presence moves to a new room
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. |
||
|
|
d87fc36d21 |
perf(nests): index room presence separately from chat for O(speakers) scans
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. |
||
|
|
22e50cc852 |
perf(feed): viewport-aware metadata loading with batched author filter
Two optimizations for feed metadata loading speed: 1. Batched author filter (FeedMetadataCoordinator.loadMetadataBatched): Single Filter(kind:0, authors:[all visible]) instead of individual per-pubkey subscriptions through rate limiter. Closes after EOSE. Follows ChessRelayFetchHelper one-shot pattern. Max 100 authors/filter. 2. Viewport-aware scroll observation (FeedScreen): snapshotFlow reads LazyListState.visibleItemsInfo (zero recomposition) with 500ms debounce + distinctUntilChanged. Only fetches metadata for visible notes ± 10 item buffer. Initial load batches first 30 notes. Before: 100+ authors × 20/sec rate limit × 7 relays = 5+ seconds After: ~20 visible authors × 1 batched sub × 7 relays = <1 second Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
156391ec0a |
fix(multi-account): persist display names in encrypted storage, fix npub-only fallback
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> |
||
|
|
250bb5a1ad |
feat(multi-account): display names, middle-truncated npub, npub-only account fix
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> |
||
|
|
ee860b92a1 | Adjusts the roudabout way of making the chat screen | ||
|
|
98e62b167b |
Merge pull request #2621 from vitorpamplona/claude/review-nostr-nests-compliance-hKBnS
feat(nests): proactive JWT refresh + reconnect for speaker path |
||
|
|
d41a24f945 |
feat(nests): empty-stage hint, local hush, moderator/force-mute moderation
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. |
||
|
|
bed1b328ce |
fix(nests): self-terminate the audio-level emitter when no one is talking
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().
|
||
|
|
04ee2c3106 |
feat(nests): pulse the speaker ring with live audio level
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). |
||
|
|
d37eb10b8c |
feat(nests): proactive JWT refresh + reconnect for speaker path
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 |
||
|
|
3c3e327bcb | Fixes test cases | ||
|
|
15169de7e5 |
fix(nests): drop themed colors when palette is incomplete
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 |
||
|
|
dc3ac31ae4 |
refactor: rename Audio Room → Nest project-wide
Aligns class names, package paths, string resource keys, UI text and intent actions with the Nests branding used by the EGG specs in nestsClient/specs/. Mechanical rename — no behavior change. - Folders: audiorooms/ → nests/ (5 paths across amethyst, quartz) - 30+ class renames (AudioRoom* → Nest*, AudioRooms* → Nests*) - String resource keys audio_room_* → nest_* - UI strings "Audio Room"/"Audio Rooms" → "Nest"/"Nests" (incl. all locales) - Intent extras AUDIO_ROOM_* → NEST_* - Compose route Route.AudioRooms → Route.Nests Spec-aligned identifiers (MeetingSpaceEvent, meetingSpaces/, the nip53* packages) are intentionally untouched — those are NIP-53 protocol names, not "audio room" branding. https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b |
||
|
|
c75c7c5599 |
fix(audio-rooms): VM catalog/buffering lifecycle leaks (audit #1-4)
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).
|
||
|
|
062944de83 |
feat(audio-rooms): announce-driven speaker discovery (T4 #17b)
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.
|
||
|
|
c3ff82913b |
feat(audio-rooms): pre-roll buffering overlay on speaker avatars
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.
|
||
|
|
d9fe3b5f83 |
feat(audio-rooms): consume moq-lite catalog metadata in VM
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.
|
||
|
|
05e7e57c4b |
refactor(audio-rooms): VM uses connectReconnectingNestsListener (T4 #2)
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.
|
||
|
|
14863415d5 |
feat(audio-rooms): font-tag parser + system-font typography (T3 #1)
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).
|
||
|
|
2e38aa6c77 |
feat(audio-rooms): NestsReconnectPolicy + Reconnecting state (T4 #2 foundation)
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.
|
||
|
|
440fc61104 |
feat(audio-rooms): RoomTheme projection from kind-30312 (T3 #1)
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.
|
||
|
|
5cb2351cdb |
feat(audio-rooms): RoomMember + ParticipantGrid pure projection (T2 #1)
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. |
||
|
|
d1be2ae19e |
feat(audio-rooms): kick + per-participant host actions (T1 #6)
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
|
||
|
|
aa4c24b974 |
feat(audio-rooms): recentReactions StateFlow on AudioRoomViewModel
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. |
||
|
|
87a54bf479 |
feat(audio-rooms): RoomReaction + sliding-window aggregator
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. |
||
|
|
25a8c460aa |
feat(audio-rooms): chat ledger on AudioRoomViewModel
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. |
||
|
|
052fdce655 |
feat(audio-rooms): expose presences StateFlow on AudioRoomViewModel
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 |
||
|
|
87dd8ee2ec |
feat(audio-rooms): RoomPresence + aggregator for listener-side fan-in
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. |
||
|
|
9c3cbdaee6 |
feat(audio-rooms): emit publishing + onstage tags on presence heartbeat
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)
|
||
|
|
beec8204e5 |
refactor(audio-rooms): NestsClient API matches nostrnests reality (phase 2/3)
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.
|
||
|
|
2a932dc974 |
fix(audio-rooms): clean up deferred audit items (VM + Android + MoQ comment)
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. |
||
|
|
ed793e8eb3 |
fix(audio-rooms): round-2 audit — pump self-join (CRITICAL) + 5 other findings
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. |
||
|
|
0b1ec52f79 |
fix(audio-rooms): audit follow-up — MoQ HIGH/MED + VM concurrency + Android polish
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. |
||
|
|
f0b27654ba |
test(audio-rooms) + fix: round-trip test + audit pass
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).
|
||
|
|
0a45d1094f |
feat(audio-rooms): M8 polish + M3/M9 foreground service
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.
|