Commit Graph

12201 Commits

Author SHA1 Message Date
Claude be227fc966 feat(audio-rooms): host hand-raise queue UI (T1 #5)
Surfaces audience members who raised their hand (kind-10312
presence with `hand=1`) and gives the host a one-tap "Approve"
button that promotes them to SPEAKER via
RoomParticipantActions.setRole.

  HandRaiseQueueSection — Compose section visible host-only.
  Filters the VM's `presences` map to entries where
  handRaised=true AND the pubkey isn't already a host / moderator /
  speaker (they wouldn't have raised their hand if they could
  already speak). Hidden when the queue is empty so a quiet room
  doesn't gain a placeholder.

  AudioRoomFullScreen — gates the section on `isHost` and stitches
  it in below the audience row.

  Strings: `audio_room_hand_raise_queue_title`,
           `audio_room_hand_raise_approve`.

Approve flow:
  presence.hand=1 → RoomParticipantActions.setRole(event, target,
  ROLE.SPEAKER) → account.signAndComputeBroadcast → relay sees
  same `d`-tag → kind-30312 replaced. The promoted user's
  participant tag now reports canSpeak() == true; their VM's
  startBroadcast() gating in commons unblocks them automatically
  on the next render.
2026-04-26 22:41:54 +00:00
Claude 6d1299c72a feat(audio-rooms): pure builders for promote / demote (T1 #5)
Adds the host-side participant-list mutations as pure functions —
extracted to make every behaviour unit-testable without an
AccountViewModel / signer:

  RoomParticipantActions.setRole(original, target, newRole)
  RoomParticipantActions.demoteToListener(original, target)

Both republish kind-30312 with the same `d`-tag so the relay
treats it as a replacement of the original. Internally:
  * If the target was already a participant, their tag is updated
    in place — no duplicate `p`-rows.
  * If they weren't (audience member promoting up from the room),
    a fresh `p`-tag is appended.
  * The host can NEVER be demoted — there's exactly one host per
    NIP-53 audio room and the host's pubkey IS the event author,
    so flipping their role would produce a corrupt event. Returns
    null instead.
  * Every other promoted participant is preserved verbatim — same
    risk-mitigation as EditAudioRoomViewModel.buildEditTemplate.

6 unit tests covering:
  * Promote-when-absent appends with the requested role
  * Promote-when-present updates in place (no duplicate row)
  * Host can't be demoted
  * Demote flips a speaker to PARTICIPANT, leaves moderators alone
  * Republish keeps the same `d`-tag
  * Promoting one speaker doesn't disturb the other speakers

Sheet/menu wiring + the hand-raise queue UI come next.
2026-04-26 22:40:37 +00:00
Claude 9fa6f756ca feat(quartz): role-check helpers on ParticipantTag
Adds typed role accessors to make participant-list filtering
self-documenting:

  ParticipantTag.effectiveRole(): ROLE? — case-insensitive parse;
  null when the role string doesn't match any enum value (so an
  unknown future "director" role doesn't accidentally pass canSpeak).

  ParticipantTag.isHost() / isModerator() / isSpeaker() — single-role
  predicates for per-row UI gating.

  ParticipantTag.canSpeak() — true for HOST / MODERATOR / SPEAKER;
  the audio-room VM uses this to gate startBroadcast() so anyone who
  was promoted (not just the original host) can publish.

5 unit tests cover happy paths, case-insensitivity, the
unknown-role → null contract, the canSpeak union, and the
effectiveRole enum parse.

Tier 1 #5 + #6 consume these — promote/demote and kick both need
to render different rows depending on whether the local user is a
host or moderator (allowed to manage roles) and the target
participant's current role.
2026-04-26 22:38:39 +00:00
Claude e9aefed496 feat(audio-rooms): host edit + close room (T1 #6)
Adds the host-only "edit / close room" flow:

  EditAudioRoomViewModel — pre-fills from the existing
                           MeetingSpaceEvent on bind. save() and
                           closeRoom() both republish kind-30312
                           with the SAME `d`-tag (relay treats it
                           as a replacement). Closing flips
                           status=CLOSED; everything else just
                           edits the form fields. Participants are
                           preserved verbatim — promoting a speaker
                           is a separate action (Tier 1 #5), so
                           edit must NEVER silently demote anyone.

  buildEditTemplate      — pure template builder extracted out so
                           it's unit-testable without an
                           AccountViewModel / signer (the
                           sign+broadcast path is the only side
                           effect).

  EditAudioRoomSheet     — bottom-sheet copy of CreateAudioRoomSheet
                           with a destructive "Close room" button
                           on the left and "Save" on the right.
                           Title, error and progress states wired
                           to the same VM.

  AudioRoomFullScreen    — host-only overflow menu (kebab) next to
                           the room title; "Edit room" opens the
                           sheet. Hidden when the local pubkey
                           isn't the room's pubkey.

  EditAudioRoomViewModelTest — 4 unit tests covering:
    * republish keeps the same d-tag and updates room/summary
    * republish preserves all promoted participants (host + 2
      speakers) — none lost on rename
    * closeRoom flips status to CLOSED with the same d-tag
    * blank summary / image OMIT the corresponding tags so a
      "delete the summary" edit actually deletes it

  Strings — `audio_room_edit_title`, `audio_room_edit_save`,
            `audio_room_close_action`, `audio_room_overflow_menu`.

Scheduled rooms (#7) deliberately deferred to a follow-up — the
StatusTag enum currently has only OPEN/PRIVATE/CLOSED on
MeetingSpaceEvent, no PLANNED. That's an additive Quartz change
plus a date-time picker on the create flow; better to land in its
own commit.
2026-04-26 22:26:23 +00:00
Claude 3e3ce4ea3c feat(audio-rooms): kind-7 reactions wire + overlay + picker
End-to-end glue for the speaker-avatar reaction overlay (T1 #3):

  RoomReactionsFilterAssembler — REQs `kinds=[7], #a=[roomATag]`
  on the user's outbox relays. Mirror of RoomChatFilterAssembler
  / RoomPresenceFilterAssembler. Kind 9735 (zaps) deliberately not
  in the v1 filter; the overlay only renders kind-7 reactions for
  now and adding zaps later is a one-line filter change.

  AudioRoomActivityContent — opens the wire sub on enter, observes
  LocalCache for ReactionEvents matching this room's #a-tag, pipes
  each event into vm.onReactionEvent. A separate 1-s tick drives
  vm.evictReactions so the floating-up overlay's lifetime is set by
  the eviction tick rather than per-component timers.

  SpeakerReactionOverlay — small Composable: groups duplicate emojis
  into "🔥 ×3" chips ordered by most-recent createdAt. Hidden when
  the per-pubkey list is empty; the 30-s sliding window in the VM
  takes care of clearing.

  StagePeopleRow now accepts an optional `reactionsByPubkey` map
  and renders the overlay below each speaker's avatar. The audience
  row keeps the empty default — reactions land on speakers.

  RoomReactionPickerSheet — bottom-sheet quick-picker with six
  defaults (👏 🔥 😂 👀 ❤️ ). Tapping an emoji invokes
  AccountViewModel.reactToOrDelete on the room note, which builds
  + signs + broadcasts the kind-7. Custom emoji-pack support is
  deferred to a follow-up.

  AudioRoomFullScreen — adds a "React" OutlinedButton next to the
  hand-raise toggle that opens the picker. Now plumbs the room's
  AddressableNote through (needed for reactToOrDelete).

  strings.xml — `audio_room_reactions_title`,
  `audio_room_reactions_button`.
2026-04-26 22:19:40 +00:00
Claude 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.
2026-04-26 22:14:37 +00:00
Claude 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.
2026-04-26 22:13:24 +00:00
Claude a5cc594416 feat(audio-rooms): live chat panel on the room screen
Wires up the kind-1311 chat end-to-end (T1 #2):

  AudioRoomActivityContent — opens RoomChatFilterAssemblerSubscription
                             on enter, observes LocalCache for kind=1311
                             + #a=[roomATag], pipes events into the
                             VM's chat ledger via onChatEvent.

  AudioRoomChatPanel       — new Composable. LazyColumn of messages
                             (auto-scroll-to-bottom on new arrival),
                             empty placeholder, OutlinedTextField +
                             Send button. Each row shows a
                             ClickableUserPicture by pubkey hex,
                             a truncated-pubkey label, and the
                             content. Rich author-name lookup is
                             intentionally deferred to keep the v1
                             panel's dependency surface tight.

  Send path                — `account.signAndComputeBroadcast(
                                LiveActivitiesChatMessageEvent.message(
                                    post = trimmed,
                                    activity = ATag(30312, host, dTag, null)))`
                             routed directly from the Composable so
                             the VM stays free of platform/signing
                             dependencies.

  AudioRoomFullScreen      — embeds the panel below the existing
                             hand-raise / leave row.

  strings.xml              — `audio_room_chat_send`,
                             `audio_room_chat_placeholder`,
                             `audio_room_chat_empty`.

Sub closes on Composable dispose; relay traffic naturally winds down
when the user leaves the room. Out-of-order arrivals are sorted to
chronological by the VM's ledger (already covered by the
AudioRoomViewModelTest unit tests).
2026-04-26 22:09:49 +00:00
Claude 3a3d53a437 feat(audio-rooms): per-room kind-1311 chat subscription
Adds the relay-side wire sub for the live-chat panel — REQs
`kinds=[1311], #a=[roomATag]` against the user's outbox relays
while the room screen is composed.

  RoomChatQueryState / RoomChatFilterSubAssembler /
  RoomChatFilterAssembler — mirror of RoomPresenceFilterAssembler;
  same shape so a future refactor can dedupe.

  RoomChatFilterAssemblerSubscription — Composable lifecycle
  wrapper. Single file (no separate "*Subscription.kt") since
  the assembler + composable are tightly coupled to one event kind.

  RelaySubscriptionsCoordinator.roomChat — registered alongside
  `audioRooms` and `roomPresence` so accountViewModel.dataSources()
  exposes it on the same surface the rest of the screen-scoped
  subs use.

Events arriving in LocalCache are picked up by AudioRoomActivityContent
in the next commit, where they're piped into the VM's chat ledger.
2026-04-26 22:06:59 +00:00
Claude 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.
2026-04-26 22:05:48 +00:00
Claude 2c41d989ac feat(audio-rooms): listener counter badge on the full-screen room view
Renders "%d listeners" right under the room title once the kind-10312
aggregator has at least one entry. Counts every peer with a recent
presence event — hosts, speakers, and audience alike — matching what
the nostrnests web UI shows in its room header.

The badge uses a pluralized string resource so the wire copy matches
locale rules (one / many) without per-locale code paths.
Hidden when the count is zero so a brand-new room (before anyone's
presence has propagated) doesn't flash a misleading "0 listeners".

Counts can flicker briefly when a single peer drops a heartbeat —
the eviction window in AudioRoomActivityContent is 6 min so a short
relay hiccup doesn't flip the count, but the badge will move when
peers genuinely come and go.
2026-04-26 21:59:21 +00:00
Claude 9b4f2e394d feat(audio-rooms): per-room kind-10312 presence subscription
Wires the listener-side aggregator end-to-end:

  RoomPresenceFilterAssembler — REQs kind-10312 with #a=[roomATag]
                                across the user's outbox relays.
                                Mirrors ThreadFilterAssembler's
                                shape (PerUniqueIdEoseManager keyed
                                by the room a-tag so overlapping
                                screens stay separated).

  RoomPresenceFilterAssemblerSubscription — Composable scope wrapper.
                                Opens the wire sub on enter, closes
                                on dispose, mirrors the
                                ThreadFilterAssemblerSubscription
                                pattern.

  RelaySubscriptionsCoordinator — registers `roomPresence` alongside
                                `audioRooms` so accountViewModel.dataSources()
                                exposes it the same way every other
                                screen-scoped sub does.

  AudioRoomActivityContent — calls the Composable subscription, then
                                runs two LaunchedEffects:
                                  1. observe LocalCache for
                                     MeetingRoomPresenceEvent matching
                                     this room's a-tag, pipe each
                                     event into vm.onPresenceEvent
                                  2. evict peers whose last heartbeat
                                     is older than 6 min on a 60-s tick
                                     (one missed 30-s heartbeat +
                                     5-min "still here" tolerance)

The presence map is now populated from the wire when entering an
audio room. Listener counter + participant grid UI consume
vm.presences in the next commit.
2026-04-26 21:55:21 +00:00
Claude 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
2026-04-26 21:47:57 +00:00
Claude 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.
2026-04-26 21:42:11 +00:00
Claude 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)
2026-04-26 21:39:49 +00:00
Claude 5a5eaa3b50 feat(quartz): augment kind-10312 presence with publishing + onstage
Adds two NIP-53 presence tags used by nostrnests' room UI:

  ["publishing", "0|1"] — peer is actively pushing audio packets
  ["onstage",    "0|1"] — peer holds a speaker slot vs audience

These complement the existing "hand" and "muted" tags. They're
independent: a speaker can be onstage but paused (onstage=1,
publishing=0), or broadcasting silently (onstage=1, publishing=1,
muted=1). The participant grid (Tier 2) and listener counter (Tier 1
#8) consume them.

  - PublishingTag / OnstageTag classes mirror HandRaisedTag's parse +
    assemble shape exactly so the DSL (TagArrayBuilderExt) extends
    uniformly.
  - MeetingRoomPresenceEvent.publishing() / onstage() accessor parsers
    return Boolean? (null when the tag isn't present) so callers can
    distinguish "explicitly false" from "absent".
  - The MeetingSpaceEvent overload of build() now accepts both fields
    as nullable params; the MeetingRoomEvent overload deliberately
    doesn't (those are tier-2 video meetings, not Clubhouse rooms).
  - Round-trip tests pin the wire format and the "absent → null"
    behaviour.
2026-04-26 21:32:59 +00:00
Claude a2f4adda0c test(audio-rooms): pin current speaker-close listener-flow behaviour
Speaker pushes 4 frames, then calls broadcast.close() + speaker.close().
Listener should receive the 4 pushed frames — and currently DOES NOT
auto-terminate its objects flow when the speaker disconnects, because
the relay's `subscribed complete` signal isn't propagated to a
Channel.close() on our listener side. UI code is expected to drive
its own teardown.

The test pins that current behaviour: it asserts the flow stays open
within a 5s window after speaker.close(). If a future change wires
the relay's done-signal through to a clean flow completion (the more
user-friendly behaviour), this test will fail loudly and ask you to
flip the assertion + rename the test —
`speaker_close_terminates_listener_flow_cleanly`.

Documents a known gap rather than asserting an aspirational contract,
so we don't paper over the missing path with a "happy when listener
unilaterally closes" test.
2026-04-26 21:25:46 +00:00
Claude 2b86d71c1d test(audio-rooms): one listener's unsubscribe doesn't tear down others
One speaker, two listeners A and B. Push 3 frames (both receive), A
unsubscribes, push 3 more frames, B's stream completes with all 6.

If A's unsubscribe accidentally tore down the speaker's broadcast or
B's subscribe (e.g. an over-eager session-wide cleanup in a future
refactor), B would time out waiting for the second batch — and the
test names that exact failure mode in the message.
2026-04-26 21:22:53 +00:00
Claude dcbe31d4c8 test(audio-rooms): late-joining listener doesn't replay history
Pin moq-lite-03's "from latest" subscribe semantics: a listener that
joins after the speaker has been broadcasting for a while sees only
new frames, not the pre-subscribe history.

Phase 1: speaker pushes 5 early frames (bytes 0..4) with no listener
attached. The relay does not buffer these in moq-lite-03.

Phase 2: late listener subscribes.

Phase 3: speaker pushes 5 more frames (bytes 100..104). The listener
takes the first 5 frames it sees and we assert ALL of them carry
bytes >= 100 — any contamination from phase 1 fails loudly with
the offending bytes named.

This pins the no-replay guarantee against a future regression to
"buffer-and-replay" behaviour, which would change recovery latency
characteristics for users joining mid-stream.
2026-04-26 21:21:47 +00:00
Claude d1b00dd346 test(audio-rooms): pin mute/unmute end-to-end
Verifies the broadcaster's mute path through a real moq-relay:
  - push two unmuted frames -> listener receives them
  - setMuted(true), push two muted frames -> listener never sees them
  - setMuted(false), push two more unmuted frames -> listener resumes

Asserts the listener's flow contains exactly [0, 1, 2, 3] in order
(the muted 50, 51 frames must be absent, not silently filled with
zeros). This pins `if (muted) continue` in
AudioRoomMoqLiteBroadcaster against an accidental "send a silent
placeholder while muted" regression.

Also factors the previously-private DriverCapture / StubEncoder
helpers into a shared InteropFrameDriver.kt so the new test can
reuse them without copy-pasting per file.
2026-04-26 21:20:44 +00:00
Claude 8b7ad2ebe1 test(audio-rooms): pin moq-lite-03 not-found contract for early subscribe
The previous test
`listener_subscribed_before_announce_receives_late_frames` validated a
contract from older moq-rs versions: the relay would HOLD a SUBSCRIBE
issued before any publisher announced, then resolve it once a publisher
arrived. moq-lite-03 dropped that behaviour — the relay now rejects
immediately with `subscribed error err=not found` and FINs the bidi
without writing a SubscribeDrop body, which our session reader surfaces
as `MoqProtocolException: subscribe stream FIN before reply`.

Rename the test to
`subscribe_before_announce_fails_with_not_found` and flip the
assertion to pin the new contract. Acceptance message check is
permissive: matches the current "FIN before reply" form AND a
hypothetical future "not found" SubscribeDrop, so a relay change to
explicitly Drop wouldn't silently regress this test.

The class-level coverage doc loses the obsolete
"subscribe-before-announce holds" claim and gains a note on why
the test now flips.

Verified end-to-end against moq-relay 0.10.25 + moq-auth running
bare-metal (-DnestsInteropExternal=true): all five interop test
classes (Auth, AuthFailure, AuthEndpoints, RoundTrip, MultiPeer)
now pass — 13/13 cases green.
2026-04-26 21:06:24 +00:00
Claude 5a86cdd4e4 fix(audio-rooms): SubscribeResponse framing matches moq-lite-03
Lite-03's SubscribeResponse on the response side of a Subscribe bidi
is two pieces concatenated:

  type   varint  (0 = Ok, 1 = Drop)
  body   size-prefixed bytes

The type discriminator sits OUTSIDE the body's size prefix. Earlier
drafts wrapped the whole thing in one outer size prefix; Lite-03 split
them. See `rs/moq-lite/src/lite/subscribe.rs::SubscribeResponse::encode`
(the `_` arm, which matches Lite-03+).

Our codec was producing — and reading — the older outer-wrapped form,
so against a Lite-03 relay we mis-parsed the type discriminator (`0`
for Ok) as a "size=0" outer prefix and surfaced a confusing
`MoqCodecException: truncated varint at offset=0 (remaining=0)` from
inside `decodeSubscribeResponse`. The first byte the relay sent was
the type, not a size, and our reader peeled it off as the outer length.

Fix touches three pieces:

* `encodeSubscribeOk` / `encodeSubscribeDrop` emit
  `type + size_prefixed(body)` directly (no outer wrap), via a small
  `prefixWithType` helper.
* `decodeSubscribeResponse` reads `type` then a length-prefixed body,
  decodes the body in its own reader, and asserts the outer payload
  is fully consumed.
* `readSubscribeResponseFromBidi` walks chunks into the buffer until
  both the type varint and the size-prefixed body have arrived, then
  re-emits the contiguous `[type][size][body]` slab so the decoder
  parses it self-contained. Reuses the `EarlyExit` collector pattern
  the publisher-inbound path already uses; avoids `Flow.iterator()`
  which doesn't exist in kotlinx.coroutines.

Tests:
* `MoqLiteCodecTest::subscribe{Ok,Drop}_round_trips` no longer
  `peelSizePrefix` the encoded bytes — the new wire form has no outer
  prefix to strip; the bytes go straight through `decodeSubscribeResponse`.
* `MoqLiteSessionTest::publisher_acks_subscribe_and_pushes_group_data_on_uni_stream`
  also drops `MoqLiteFrameBuffer().readSizePrefixed()` on the ack chunk
  for the same reason.

Verified end-to-end against a bare-metal moq-relay 0.10.25 + moq-auth
(`-DnestsInteropExternal=true`):

* `NostrNestsRoundTripInteropTest::production_speaker_broadcasts_to_production_listener_via_real_relay`
  passes — speaker → listener → 8 frames round-trip cleanly.
* `NostrNestsMultiPeerInteropTest::one_speaker_fans_out_to_two_listeners`
  passes — same speaker reaches both listeners with the full frame
  stream.
* The `listener_subscribed_before_announce_receives_late_frames` and
  `two_speakers_in_same_room_deliver_independently_to_one_listener`
  cases still fail with `subscribe stream FIN before reply` — those
  are different relay-behavior issues (the v1 relay seems to FIN
  subscribes against unannounced broadcasts and against a second
  speaker on the same listener), unrelated to the codec.
2026-04-26 20:55:04 +00:00
Claude da1c4d3968 fix(audio-rooms): advertise moq-lite-03 in WT CONNECT sub-protocols
Without `wt-available-protocols`, moq-relay (`web-transport-quinn`) falls
back to the legacy in-band SETUP exchange (moq-lite-02) instead of
selecting the moq-lite-03 sub-protocol from the ALPN-style negotiation
header. Then the relay tries to decode our first post-CONNECT bytes as a
SETUP_CLIENT message, hits an unknown control type, and closes the QUIC
connection with `connection closed err=invalid value` — surfaced
client-side as a stuck SUBSCRIBE that ends with
`subscribe stream FIN before reply for id=0` (the bidi gets FIN'd because
the whole connection is being torn down).

Pass `wt-available-protocols: "moq-lite-03"` on the Extended CONNECT
request, encoded as an RFC 8941 Structured Field List of strings (the
header format mandated by draft-ietf-webtrans-http3-14 §3.3). With this,
moq-relay logs `negotiated version=moq-lite-03 transport="quic"` and the
SUBSCRIBE makes it into the relay's actual moq-lite session pump.

Mechanism: web-transport-proto's `ConnectRequest::encode` reads
`self.protocols` and writes them as a comma-separated list of bare
strings under `wt-available-protocols`. The server side (web-transport-
quinn) reads the same header into `request.protocols`, and moq-native's
`QuinnRequest::ok()` picks the first match against its supported ALPN
list (`moq-lite-04`, `moq-lite-03`, `moq-00`, `moqt-15`, etc.). On
match, version selection happens via the WT sub-protocol response and
the in-band SETUP is skipped — which is what moq-lite-03 expects.

Default the factory list to `["moq-lite-03"]`. Callers that want a
different version (or to disable sub-protocol negotiation entirely
to talk to a SETUP-based server) override the constructor parameter.

Bare-metal harness: NostrNestsHarness.startExternal() now skips the
TCP probe of the moq-relay port. moq-relay binds UDP only; the Docker
forwarder happens to also open TCP, but a directly-launched binary
doesn't, so the previous `Socket(host, 4443)` probe failed with
ConnectException. The QUIC handshake from the test surfaces a real
transport problem if any.
2026-04-26 20:40:19 +00:00
Claude d00e406587 test(audio-rooms): -DnestsInteropExternal bypasses Docker
Adds a "bring your own stack" path to NostrNestsHarness so the interop
tests can run without a Docker daemon. With `-DnestsInteropExternal=true`:

  - Skip the `docker compose up` that builds + boots
    moq-auth + moq-relay
  - Port-probe + /health-check the same 8090 / 4443 endpoints the
    Docker path uses
  - close() is a no-op — the caller owns the lifecycle

Useful in two situations:
  1. Sandboxes / restricted CI without a Docker daemon (just run
     `cargo install moq-relay` + `node moq-auth/dist/index.js`
     yourself, then run gradle with the flag)
  2. Fast iteration — the Docker path takes ~30 s to compile
     moq-relay on first run; with this, you keep both processes
     alive across many test invocations

Forward `nestsInteropExternal` (and `nestsInteropDebug`,
`nestsInteropMoqRev`) through the Test task so the property reaches
test workers; without that, the gate stays off in the worker JVM.

Also: `assertSpeakerReached` / `assertListenerReached` now log a "✘"
checkpoint with the rich state description before calling fail().
JUnit captures the assertion message in a separate section, but the
"standard output" tab is what most people read first when scanning
for a cause — so the chained-cause string now lands in both places.
2026-04-26 20:23:45 +00:00
Claude 1a26b23448 style(audio-rooms): import-and-use instead of inline FQNs
Replace inline `com.vitorpamplona.…` references in code + KDoc with
imports + the short class name across the audio-rooms surface. KDoc
references that would have introduced an `ui` → `model` import cycle
(e.g. NestsServerListState referring to CreateAudioRoomViewModel) are
demoted to plain text instead of clickable `[Class]` links.

No behavioural change.
2026-04-26 20:11:02 +00:00
Claude 5f90588fe4 test(audio-rooms): InteropDebug step logger + harness diagnostics
When an interop test fails today the report shows a generic
AssertionError pointing at a `runBlocking {` line — useless for
narrowing down which sub-step (mintToken, WT connect, ANNOUNCE,
SUBSCRIBE_OK, frame round-trip) actually exploded.

This adds a small InteropDebug helper that:
  - prints a labelled "▶ start" / "✔ ok" / "✘ fail — Class: msg ⟵ Cause: msg"
    trail per step (output gated on `-DnestsInterop=true` so the
    default test run stays silent)
  - walks chained `cause` so the surface message reveals the real
    network / protocol error instead of the wrapping string
  - pretty-prints NestsSpeakerState / NestsListenerState (Failed in
    particular unwraps `cause` for the report)

Each test body in NostrNestsRoundTripInteropTest +
NostrNestsMultiPeerInteropTest now wraps connect / startBroadcasting /
subscribeSpeaker / await-frames in `InteropDebug.stepSuspending(...)`
so a failure points at the exact sub-step rather than the test method.

Harness diagnostics: when `start()` fails, capture
`docker compose ps` + recent logs for moq-auth / moq-relay / strfry
into the IllegalStateException message before tearing the stack down.
Without this, the test report only shows "exited with code 1" —
operators have to re-run by hand to find out which container
crashed.

Output is hidden by default; only surfaces when
`-DnestsInterop=true` (or the explicit `-DnestsInteropDebug=true`)
is set.
2026-04-26 20:10:43 +00:00
Claude e71a2b26ef docs(audio-rooms): coding plans for Tier 1-4, one file per tier
The earlier integration audit identified the gaps; this is the
how-to-build-them. Split across four files plus an index so each
review / commit stays small:

  - 2026-04-26-tier-plans-index.md — top-level pointer + sequence
    dependencies + what's deliberately out of scope.

  - 2026-04-26-tier1-coding-plan.md — listener counter, presence
    aggregation, augmented kind-10312 tags (publishing/onstage),
    live chat (kind 1311), reactions (kind 7 / 9735), edit + close
    + scheduled rooms, role parsing + promote/demote, hand-raise
    queue, kick (kind 4312). Concrete file-level wiring for each
    step + suggested commit order (six independent PRs).

  - 2026-04-26-tier2-coding-plan.md — participant grid,
    per-avatar context menu (follow / mute / zap / promote / kick),
    zap entry points (room + speaker), share-via-naddr.

  - 2026-04-26-tier3-coding-plan.md — room theming PARSER ONLY
    (graceful fallback for themed rooms; full theming behind a
    later phase), background-audio + wake-lock audit checklist.

  - 2026-04-26-tier4-coding-plan.md — moq-auth token re-mint on
    long sessions and moq-lite Connection.Reload-equivalent
    reconnect with backoff. Step 1 is subsumed by Step 2 once the
    reconnect path is in.

No code changes — pure docs. Each plan names exact file paths,
new types, reused helpers, strings, tests, and call-out risks so
an implementer (or follow-up agent) can pick up Step N without
re-deriving the surrounding context.
2026-04-26 19:52:07 +00:00
Claude 74d5e77a83 fix(audio-rooms): retry mintToken on transport hiccup + harness /health warmup
The remaining interop failures all root in the same window: a stale
keep-alive pool entry from one test class is reused on the FIRST POST
of the next test class, the connection RSTs as the request body
writes, and OkHttp's built-in retryOnConnectionFailure won't retry a
POST after any byte of the body has gone out. Same situation hits a
phone client whose Wi-Fi hands off mid-mint.

Two fixes, both production-shaped:

  - OkHttpNestsClient.mintToken now wraps execute() in
    executeWithTransportRetry(): one retry on SocketException /
    EOFException / generic IOException. Request builders are
    immutable, so the second pass opens a fresh connection cleanly.
    HTTP error status codes (4xx / 5xx) and malformed responses are
    NOT retried — they go to the caller as before.

  - NostrNestsHarness now polls GET /health until it returns 200
    after the port-probe succeeds. moq-auth's Node runtime opens its
    listen socket before the request handlers are wired, so a POST
    that arrives in that window can RST. Waiting for /health proves
    the request pipeline is live, eliminating the SocketException
    that hit the first test of every test class run after
    AuthEndpoints.

Symptoms fixed:
  - NostrNestsAuthFailureInteropTest.missing_authorization_header_is_rejected_401
    -> SocketException
  - NostrNestsRoundTripInteropTest.production_speaker_broadcasts_to_production_listener_via_real_relay
    -> "Failed to reach http://127.0.0.1:8090/auth"
  - NostrNestsMultiPeerInteropTest.* (3 tests, same root cause)
2026-04-26 19:46:38 +00:00
Claude 7e67c4655f fix(audio-rooms): share Docker harness across all interop test classes
Each interop test class was running its own NostrNestsHarness.start()
in @BeforeClass and harnessOrNull?.close() in @AfterClass — meaning
the Docker stack tore down + spun back up between every class. That
sequence was both slow (~30 s Cargo build for moq-relay each time)
and unreliable: leftover network state from the prior `down -v` was
racing the next `up -d`, leaving moq-auth either unreachable
(SocketException) or producing truncated 401 responses (EOFException
on body.string()) for tests that ran after the first.

Symptoms before this fix (first class wins; everything else fails):
  - NostrNestsAuthEndpointsInteropTest                       
  - NostrNestsAuthInteropTest          → SocketException     
  - NostrNestsAuthFailureInteropTest   → EOFException        
  - NostrNestsRoundTripInteropTest     → "Failed to reach"   
  - NostrNestsMultiPeerInteropTest     → "Failed to reach"   

Fix: NostrNestsHarness.shared() returns a process-singleton. First
caller does the docker compose up + port-probe; every subsequent
caller reuses the same containers. Teardown is registered once via
Runtime.addShutdownHook so the stack lives for the JVM's lifetime
and dies cleanly at test process exit.

All five test classes now call shared() in @BeforeClass; the
@AfterClass blocks no longer call close() on the singleton (clearing
the local reference is enough — the shutdown hook handles the real
teardown). The original start() entry point is preserved for callers
that want a per-call harness.
2026-04-26 19:30:25 +00:00
Claude 8b5af5d496 docs(audio-rooms): refresh against shipped state + nostrnests gap audit
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.

  - 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
    layout: implementation table for every protocol/transport/UI
    surface, "pending" table for the remaining items (reconnect,
    level meters, Desktop / iOS, Nests parity), pointers section
    refreshed.
  - 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
    that landed it (fb47a4c71cf99d015b0d7); "When picking up"
    section now points at the shipped surface first, raw protocol
    references second.
  - 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
    today's nostrnests reality:
      * status banner up top calling out the revision
      * dependencies dropped IETF MoQ-transport, added moq-lite
        Lite-03 + ALPN "moq-lite-03"
      * HTTP control plane: GET <service>/<room-d-tag> → POST /auth
        with {namespace, publish}, returning {token}; documented the
        JWT claim shape (root, get, put), 600 s lifetime, regex
        on `namespace`, JWKS endpoint, error matrix
      * Audio transport: replaced IETF SETUP / TrackNamespace tuples
        / OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
        per-bidi message types, group uni streams, audio/data track,
        no in-band SETUP, FIN-as-unsubscribe semantics)
      * New event-kind sections: kind 4312 (admin command / kick),
        kind 10112 (audio-room server list)
      * Reconciliation section explaining what changed from the
        original draft and why
  - NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
    every nostrnests/NestsUI feature we don't yet ship, sourced from
    a code-walk of the React app + moq-auth + API.md (which is
    LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
    reactions, role parsing + promotion, hand-raise queue, kick
    (kind 4312), edit/close room, scheduled rooms, listener counter.
    Tier 2: participant grid, augmented presence tags
    (publishing/onstage), per-avatar context menu + zap, share via
    naddr. Tier 3: room theming. Tier 4: token-refresh +
    Connection.Reload sanity checks.

Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
2026-04-26 19:23:41 +00:00
Claude 015b0d7dac fix(audio-rooms): switch NestsServersEvent to kind 10112 (nostrnests claim)
nostrnests's reference README under "Nostr Integration" already declares:

  kind:10112 — User-published audio server lists

We initially picked 10062 (mirroring BlossomServersEvent's 10063) without
spotting that prior claim. Switching to 10112 so a single replaceable
event surfaces in both Amethyst and nostrnests's web UI without
collision.

No migration cost — no users have published kind 10062 yet.
2026-04-26 19:11:50 +00:00
Claude 13e84f276d fix(audio-rooms): clone kixelated/moq + run generate-certs in harness
The previous --recurse-submodules fix turned out to be moot:
nostrnests's docker-compose-moq.yml references `./moq` as a build
context, but the moq directory is NOT in the nostrnests repo and
is NOT a submodule — each developer is expected to clone
kixelated/moq into ./moq themselves before running compose.
Confirmed against the upstream README + repo listing
(nostrnests/nests has moq-auth/, nests-relay/, NestsUI-v2/ but no
moq/ directory).

Two new harness steps before `docker compose up -d`:

  - ensureMoqSource — clone https://github.com/kixelated/moq.git
    into <nests-cache>/moq on first run; fetch + checkout
    DEFAULT_MOQ_REVISION on every run so the build is reproducible.
    Override the pin via -DnestsInteropMoqRev=<sha-or-branch>.

  - ensureDevCerts — run dev-config/generate-certs.sh to produce
    the self-signed TLS chain moq-relay mounts read-only at
    /certs. The script is idempotent (bails when fullchain.pem
    exists) so we always invoke it; a chmod +x defensively
    handles Windows / odd CI checkouts.

Reverted the spurious --recurse-submodules + submodule update path
since the repo doesn't have submodules at all — the original `git
clone` was correct, just incomplete.
2026-04-26 19:08:05 +00:00
Claude 64859546b3 fix(audio-rooms): clone nostrnests with submodules so docker compose finds moq/
The harness was running plain `git clone`, but
docker-compose-moq.yml references `./moq` (kixelated/moq-rs) and
`./moq-auth` as build contexts via git submodules. Without
--recurse-submodules the directories don't exist and `docker
compose up -d` fails with:

  unable to prepare context: path '<cache>/nests/moq' not found

Fix: clone with --recurse-submodules on first run, and sync
submodules after every fetch+checkout so the working tree
matches whatever revision pin the checked-out commit declares.
2026-04-26 18:56:31 +00:00
Claude 364b2cd926 feat(audio-rooms): start space FAB + Nests servers settings (kind 10062)
Two adjacent additions so users can both create and host their own
audio rooms from inside Amethyst:

Create-space flow
  - CreateAudioRoomSheet — modal bottom sheet on AudioRoomsScreen
    surfaced by a new "Start space" FAB. Fields: room name, summary,
    MoQ service URL, MoQ relay endpoint, optional cover image.
  - CreateAudioRoomViewModel — builds + signs MeetingSpaceEvent
    (kind 30312, status=OPEN, tagging the user as `host`),
    broadcasts via account.signAndComputeBroadcast, then returns
    launch info so the sheet can fire AudioRoomActivity straight
    into the freshly-published room.
  - Defaults pull the first saved Nests server (below) when present;
    fall back to https://moq.nostrnests.com.

Nests servers settings (proposed kind 10062)
  - NestsServersEvent — replaceable kind-10062 event listing the
    user's preferred audio-room MoQ servers. Wire shape mirrors
    BlossomServersEvent (one `server` tag per base URL); registered
    in EventFactory; consumed by LocalCache via consumeBaseReplaceable.
  - NestsServerListState — per-account observation state, mirror of
    BlossomServerListState, exposed on Account.nestsServers.
  - sendNestsServersList on Account; included in
    accountSettingsEvents() so an outbox change republishes it.
  - Filter additions: BasicAccountInfo + AccountInfoAndLists now
    request kind 10062 from relays.
  - NestsServersViewModel + NestsServersScreen — Settings UI to
    add / remove / reset to recommended servers (currently just
    nostrnests.com). Wired into AllSettingsScreen as "Audio-room
    servers"; routed via Route.EditNestsServers.
  - kind_nests_servers label for RelayInformationScreen.

Default suggestion list lives in DEFAULT_NESTS_SERVERS at the top
of NestsServersScreen — add new community-run moq-rs deployments
there as they come online.
2026-04-26 18:53:31 +00:00
Claude 76b772ab41 test(audio-rooms): forward -DnestsInterop to test workers
Without this, `-DnestsInterop=true` on the Gradle command line was
only set on the Gradle JVM, not on the test executor JVM that
NostrNestsHarness.isEnabled() reads via System.getProperty. Result:
every interop test silently skipped no matter how the run was
invoked.

Also forwards `nestsInteropRev` (used by the harness to pin the
nostrnests revision in the cache).

Verified with `./gradlew :nestsClient:jvmTest --tests
NostrNestsAuthInteropTest -DnestsInterop=true` that the harness
now actually runs (the run only fails inside the test because the
sandbox blocks outbound traffic to GHCR / Docker Hub — both
registries return 503 at the auth-token endpoint, so we can't
pull strfry / build moq images here. On a network-enabled host
the harness will bring up the stack normally).
2026-04-26 18:28:11 +00:00
Claude bc43168032 chore(audio-rooms): post-moq-lite cleanup + KDoc + plan refresh
Tidy items now that the moq-lite swap is complete on both sides:

  - Drop the no-op `supportedMoqVersions: List<Long>` parameter from
    `connectNestsListener` / `connectNestsSpeaker`. moq-lite negotiates
    via ALPN, no caller passed a value, and the project rule forbids
    no-op back-compat shims.
  - `NostrNestsRoundTripInteropTest` KDoc + comments now describe the
    moq-lite framing path (one Subscribe bidi for `audio/data`, group
    uni streams with `DataType=0` + GroupHeader + size-prefixed frames)
    instead of the stale IETF "OBJECT_DATAGRAMs / SETUP" framing.
  - `DefaultNestsListener` / `DefaultNestsSpeaker` KDoc now flags them
    as IETF MoQ-transport reference impls — production uses
    `MoqLiteNests*`. They stay around for the IETF unit-test suite.
  - `audio-rooms-completion.md` Phase M5 / M6 / M7 marked **DONE** —
    the plan was written before the moq-lite gap was discovered, so
    it described the work as IETF-MoQ-publisher additions; the
    moq-lite path lands the same outcome via a different protocol.
2026-04-26 18:18:33 +00:00
Claude 71cf99dc22 feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)
Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.

Transport layer:
  - WebTransportSession.incomingBidiStreams() — peer-initiated bidi
    flow. moq-lite publishers receive Announce + Subscribe bidis from
    the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
    Stream::accept(session)), so the abstraction grew the
    accept-bidi-from-peer surface.
  - WebTransportSession.openUniStream() — locally-opened uni stream
    for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
    session.open_uni()).
  - :quic WtPeerStreamDemux StrippedWtStream now carries optional
    send/finish closures. The demux takes the QuicConnectionDriver
    so wakeups fire after each app-level write on a peer-initiated
    bidi.
  - FakeWebTransport now exposes incomingBidiStreams + openUniStream
    directly; the openPeerUniStream test helper went away (production
    flow covers it).

Session layer:
  - MoqLiteSession.publish(suffix) — claims a broadcast suffix and
    lazily launches a relay→us bidi pump. ControlType=Announce reads
    AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
    body, replies SubscribeOk, registers the inbound subscription.
  - MoqLitePublisherHandle — startGroup / send / endGroup / close
    semantics. send opens a uni stream per group with DataType=0 +
    GroupHeader and pushes varint(size)+payload frames. close emits
    Announce(Ended) on every active announce bidi, FINs the uni.

Application layer:
  - AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
    drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
    its unit tests).
  - MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
    MoqLiteNestsListener on the publish side.
  - connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
    returns MoqLiteNestsSpeaker.

Tests:
  - 4 new MoqLiteSessionTest cases:
    publisher_replies_to_announcePlease_with_active_announce,
    publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
    publisher_send_returns_false_when_no_inbound_subscriber,
    publisher_close_emits_ended_announce.

Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.

Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
2026-04-26 18:01:00 +00:00
Claude 5914e9e9fc docs(audio-rooms): moq-lite listener landed — refresh status callouts
Phase 5d wrapped, so the doc set now reflects "listener path done,
speaker pending":

  - nestsClient/plans/2026-04-26-moq-lite-gap.md — new "Implementation
    status" section maps phases 5a → 5d to commits, calls out the
    speaker side as blocked on a small `WebTransportSession.acceptBidi`
    extension (since publisher.rs:40 uses Stream::accept), and points
    at the existing :quic primitive (QuicConnection.awaitIncomingPeerStream)
    that the bridge can lean on.
  - nestsClient/plans/2026-04-26-audio-rooms-completion.md — Phase M1
    is no longer "on hold for moq-lite"; manual nostrnests.com
    validation should now work end-to-end on the listener path.
  - .claude/CLAUDE.md — :nestsClient now hosts both IETF MoQ-transport
    and moq-lite Lite-03; production listener path uses moq-lite.
2026-04-26 17:39:23 +00:00
Claude 41f4dcd9ac feat(audio-rooms): connectNestsListener uses moq-lite (phase 5d)
Production wiring switch — `connectNestsListener` now opens a
moq-lite (Lite-03) session against the WebTransport peer instead of
running the IETF MoQ-transport SETUP handshake. The downstream
NestsListener interface is unchanged; consumer code in commons /
amethyst keeps working.

Listener flow (per the audio-rooms NIP draft + nostrnests JS reference):
  - subscribeSpeaker(pubkey) → MoqLiteSession.subscribe(broadcast =
    pubkey, track = "audio/data")
  - frames map to MoqObject so AudioRoomPlayer / AudioRoomViewModel
    keep working unchanged. Per-frame `objectId` is synthesised as a
    monotonic counter (moq-lite has no per-frame ID); `groupId` =
    moq-lite group sequence; `trackAlias` = subscribe id.

NestsListenerState.Connected.negotiatedMoqVersion now reports
MOQ_LITE_03_VERSION (a synthetic constant carrying the ALPN suffix
in the low bytes) since moq-lite has no in-band SETUP message.

NestsConnectTest:
  - Drop the SETUP-faking peer side — moq-lite has no SETUP, so
    connectNestsListener returns Connected immediately after the WT
    handshake.
  - Assert MOQ_LITE_03_VERSION on Connected.

Speaker path (`connectNestsSpeaker`) is untouched — it still uses
the IETF DefaultNestsSpeaker. Speaker-side moq-lite needs
`acceptBidiStream` on the WebTransportSession interface so the relay
can open Announce/Subscribe bidis to the publisher; tracked in
plans/2026-04-26-moq-lite-gap.md phase-5c-speaker.

Verified `:commons:compileKotlinJvm` + `:amethyst:compilePlayDebugKotlin`
both still compile against the swap.
2026-04-26 17:29:51 +00:00
Claude 4e136cacf4 feat(audio-rooms): MoqLiteSession listener-side (phase 5c-listener)
Listener-side moq-lite (Lite-03) session that wraps a
WebTransportSession and exposes:

  - announce(prefix) — opens an Announce bidi with
    ControlType=Announce + AnnouncePlease(prefix), surfaces a flow
    of relay Announce updates (Active/Ended)
  - subscribe(broadcast, track, ...) — opens a Subscribe bidi with
    ControlType=Subscribe + body, reads the size-prefixed response,
    returns a MoqLiteSubscribeHandle whose frames flow yields each
    frame the publisher pushes
  - close() — cancels pumps, FINs every active subscribe bidi
    (moq-lite UNSUBSCRIBE is "FIN the bidi"), closes the transport

Group demux: a single inbound-uni-stream pump per session reads the
DataType byte (Group=0) + size-prefixed group header, then routes
each subsequent (size, payload) frame to the matching
subscriptionsBySubscribeId entry's frame channel.

Speaker side stops at the announce/subscribe interface — moq-lite
publisher flows depend on accept_bi() / server-initiated bidi
streams (see clarifying agent summary in plans/2026-04-26-moq-lite-gap.md).
That's a follow-up; the WebTransportSession interface needs an
acceptBidiStream() method first.

FakeWebTransport.openPeerUniStream() — new helper so tests can
inject group data from the peer side without going through the
session's outbound uni stream API.

Tests in MoqLiteSessionTest — runBlocking-based (not runTest) so
real-time channel coordination works without virtual-time
artifacts:
  - subscribe_writes_request_and_returns_handle_on_ok
  - subscribe_throws_on_drop_response
  - announce_streams_relay_updates
  - groups_are_demuxed_by_subscribeId (no cross-talk)
  - unsubscribe_FINs_the_subscribe_bidi
2026-04-26 17:24:17 +00:00
Claude fb47a4cf75 feat(audio-rooms): moq-lite codec primitives + message types (phase 5a/5b)
First two phases of the moq-lite (Lite-03) implementation per
nestsClient/plans/2026-04-26-moq-lite-gap.md. No production wiring
yet — the production helpers still call the IETF MoqSession.

What landed:
  - MoqLitePath — mandatory wire-boundary normalisation (strip
    leading/trailing/duplicate `/`), join, path-component-aware
    stripPrefix. Mirrors rs/moq-lite/src/path.rs semantics.
  - MoqLiteAlpn / MoqLiteControlType / MoqLiteDataType /
    MoqLiteAnnounceStatus / MoqLiteSubscribeResponseType — wire
    enums for ALPN ("moq-lite-03"), per-bidi ControlType varints,
    Group uni-stream type byte, announce status, subscribe
    response type.
  - Message data classes — AnnouncePlease, Announce, Subscribe,
    SubscribeOk, SubscribeDrop, GroupHeader, Probe.
  - MoqLiteCodec — encode/decode for each message, with the
    size-prefix envelope baked in. Handles the off-by-one
    `0 = None, n = Some(n−1)` trick for startGroup/endGroup,
    coerces booleans to 0/1, validates priority fits in u8,
    rejects unknown status/response bytes. Path normalisation
    applied at every encode + decode boundary.
  - MoqLitePathTest, MoqLiteCodecTest — round-trip tests for
    every codec entry point + negative paths (oversized priority,
    invalid ordered byte, unknown status, trailing garbage).

All MoqWriter / MoqReader / MoqCodecException primitives reused
from the IETF MoQ codec — same varint and length-prefix shapes.
2026-04-26 16:46:55 +00:00
Claude 7f48e52541 docs(audio-rooms): full moq-lite wire spec + IETF gap call-outs
Background research turned up the complete moq-lite (Lite-03) wire
format from kixelated/moq-rs and @moq/lite v0.1.7. Folded the spec
into nestsClient/plans/2026-04-26-moq-lite-gap.md as a phase-5
implementation plan:

  - ALPN ("moq-lite-03"); no SETUP/control message in Lite-03 (the WT
    handshake IS the handshake)
  - Per-request bidi streams keyed by ControlType varint (Announce=1,
    Subscribe=2, Fetch=3, Probe=4)
  - AnnouncePlease(prefix) / Announce(status, suffix, hops) shape
  - Subscribe with priority (raw u8), ordered, maxLatency (ms),
    startGroup/endGroup (off-by-one None-encoded), reply Ok/Drop
  - Group = uni stream with (DataType=0, subscribeId, sequence)
    header followed by varint-length frames until QUIC FIN
  - No datagrams; no per-frame envelope beyond size
  - Mandatory path normalisation; FIN-as-unsubscribe; RESET_STREAM
    for errors

Phase-5a..e implementation plan included (~1 week scope).

Doc + KDoc updates so the IETF MoQ-transport code is correctly
labelled and the moq-lite gap is discoverable from every entry point:

  - .claude/CLAUDE.md project description and architecture diagram
  - nestsClient/plans/2026-04-26-audio-rooms-completion.md status block
  - MoqSession.kt, MoqMessage.kt, MoqObject.kt, MoqCodec.kt KDoc — flag
    these as "IETF draft-ietf-moq-transport-17, NOT moq-lite", with
    pointers to the gap doc
  - NestsConnect.kt — note that step 3 of the listener handshake
    (SETUP) does NOT match nostrnests's relay framing
2026-04-26 16:39:44 +00:00
Claude 1887bd1fa7 test/refactor(audio-rooms): nostrnests wire-shape fixes + interop expansion (phase 4)
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:

  1. WebTransport CONNECT path is now /<moqNamespace> (matches the
     relay's claims.root prefix check). Previously hardcoded "/anon".
  2. JWT travels in the ?jwt=<token> query parameter, not the
     Authorization header — moq-rs only reads the query param. The
     bearer-token path on QuicWebTransportFactory is now unused for
     nests; left in place for non-nests WebTransport servers.
  3. Harness `moqEndpoint` is the relay base URL only; the connect
     helpers append /<namespace>?jwt=<token> themselves.

Interop test additions (all -DnestsInterop=true gated, default-skipped):

  - NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
    rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
    for the wrong URL, malformed namespace per the strict regex,
    publish=true grant for any caller — sidecar does NOT gate by NIP-53
    hostlist).
  - NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
    shape (must contain ES256/P-256), 404 on unknown route.
  - NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
    speaker isolation, subscribe-before-announce. Code is wired through
    production connectNestsSpeaker / connectNestsListener; will pass
    once the moq-lite gap (below) is resolved.

Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
2026-04-26 15:53:02 +00:00
Claude 0ac8c0f791 test(audio-rooms): production round-trip via real MoQ relay (phase 3/3)
Drives connectNestsSpeaker + connectNestsListener end-to-end against the
real nostrnests Docker stack. Speaker announces a track, listener
subscribes by pubkey, speaker pushes deterministic frames through
AudioRoomBroadcaster → MoQ → relay → listener.objects flow, and the
test asserts payload integrity + monotonic object ids.

Validates the wire shapes the Phase-2 refactor committed to:
  - QuicWebTransportFactory + PermissiveCertificateValidator can
    handshake against the relay's self-signed dev cert
  - JWT minting + WebTransport CONNECT + MoQ SETUP all succeed
  - The single-segment TrackNamespace `nests/<kind>:<host>:<room>`
    matches the relay's `root` JWT claim

Single-keypair design sidesteps host-vs-audience auth policy so the
test stays focused on transport + protocol; a future dual-keypair
test can layer permissions on top.

Skipped by default — set -DnestsInterop=true to enable.
2026-04-26 14:50:00 +00:00
Claude 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.
2026-04-26 14:42:26 +00:00
Claude 3283d302fa test(audio-rooms): nostrnests interop harness + /auth ping (phase 1/3)
Brings up the nostrnests reference server (https://github.com/nostrnests/nests)
locally via Docker Compose so we can drive `:nestsClient`'s production
code against the real MoQ relay + NIP-98 auth sidecar.

Mirrors the `:quic` `InteropRunner` pattern (aioquic Docker, opt-in via
`-DinteropHost=…`):
- Set `-DnestsInterop=true` to enable; default `:nestsClient:jvmTest` runs
  skip via JUnit `Assume.assumeTrue` (shown as <skipped>, not <failure>).
- Repo cloned + cached at `~/.cache/amethyst-nests-interop/nests/`,
  pinned to the `DEFAULT_REVISION` (currently `main`; override via
  `-DnestsInteropRev=<sha>` to lock in for reproducibility).
- `docker compose -f docker-compose-moq.yml up -d` brings up moq-relay
  (host 4443 TCP+UDP), moq-auth (host 8090), strfry (7777). Port-probes
  4443 + 8090 with a 90 s timeout.
- `close()` runs `docker compose down -v --remove-orphans`. Tests use
  `@BeforeClass`/`@AfterClass` to amortise the ~30-60 s spin-up across
  all cases in one class.

Phase-1 ping test (NostrNestsAuthInteropTest):
- Generates an ephemeral KeyPair / NostrSignerInternal via Quartz.
- POSTs `<authBase>/auth` with `{"namespace":"nests/30312:<pubkey>:<roomId>",
  "publish":true}`, NIP-98 Authorization header signed for that exact
  (url, method, payload) tuple.
- Asserts 200 + a `"token":"…"` JWT in the response body.

Doesn't yet route through `OkHttpNestsClient` because the production
client's wire shape (GET `<base>/<roomId>` returning `{endpoint, token,
codec, sample_rate}`) does not match nostrnests' actual API (POST
`<base>/auth` with `{namespace, publish}` body, returning just `{token}`
— endpoint comes from the NIP-53 event's `endpoint` tag instead of the
HTTP response). Phase 2 of this audit refactors production to match;
this test documents the divergence on the wire so the refactor has a
clear target.

Verified: harness compiles clean; default `:nestsClient:jvmTest` shows
the test as <skipped> (not <failure>) when `nestsInterop` property is
unset.

Files:
- nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt
- nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt
2026-04-26 14:24:44 +00:00
Claude 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.
2026-04-26 13:18:51 +00:00
Claude 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.
2026-04-26 12:32:22 +00:00
Claude 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.
2026-04-26 12:04:18 +00:00
Claude 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).
2026-04-26 09:05:00 +00:00