Commit Graph

26 Commits

Author SHA1 Message Date
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
Claude 0afde79afd feat(audio-rooms): M6 + M7 — AudioRoomBroadcaster + NestsSpeaker API
M6 (AudioRoomBroadcaster): Inverse of AudioRoomPlayer. Pulls PCM frames
from an AudioCapture, runs them through an OpusEncoder, and pushes the
resulting Opus packets into a MoqSession.TrackPublisher as
OBJECT_DATAGRAMs. setMuted keeps the capture + encoder running so unmute
is sample-accurate; encode failures are reported via onError but don't
tear the loop down.

M7 (NestsSpeaker): Mirror of NestsListener for the host / speaker path.
- `NestsSpeaker.startBroadcasting()` — sends ANNOUNCE for the room's
  namespace (`["nests", roomId]`), opens a TrackPublisher named after
  this user's pubkey hex, wires an AudioRoomBroadcaster onto it.
- `BroadcastHandle.setMuted` / `close()` — speaker-side equivalents of
  the listener's mute / disconnect controls.
- `NestsSpeakerState` sealed hierarchy (Idle / Connecting{step} /
  Connected / Broadcasting{isMuted} / Failed / Closed).
- `connectNestsSpeaker` orchestration mirrors `connectNestsListener`
  end-to-end (HTTP → WebTransport → MoQ setup), then returns a
  `DefaultNestsSpeaker` ready for `startBroadcasting`.

Tests:
- AudioRoomBroadcasterTest (5 tests): pcm-flow ordering, mute behavior,
  encoder warmup skip, encode-error tolerance, idempotent stop.
- NestsSpeakerTest (2 tests): start/mute/close state transitions,
  double-start rejection.

Bug caught + fixed during M7 testing: `DefaultNestsSpeaker.close()`
held its `gate` mutex while calling `handle.close()` which then called
back through `parent.broadcastClosed()` → self-deadlock. Fixed by
making `broadcastClosed` lockless (StateFlow updates are atomic; the
activeHandle compare-and-set is benign under the gate's exclusion of
concurrent startBroadcasting calls).

Verified: `:nestsClient:jvmTest` (78 tests, all green) +
`./gradlew spotlessApply` clean.
2026-04-26 03:22:02 +00:00
Claude 5c32996f93 feat(audio-rooms): M5 MoQ publisher path — ANNOUNCE + inbound SUBSCRIBE + OBJECT emit
Lifts MoqSession from listener-only to bidirectional. A session can now
ANNOUNCE a track namespace, register one TrackPublisher per track name
under it, accept inbound SUBSCRIBEs from the peer, and emit
OBJECT_DATAGRAMs that fan out to every attached subscriber. This is what
the speaker / host path needs in nests — phases M6/M7 layer audio
capture + a NestsSpeaker API on top.

Public API additions:
- `MoqSession.announce(namespace, parameters)` — sends ANNOUNCE, awaits
  ANNOUNCE_OK, returns an `AnnounceHandle`.
- `AnnounceHandle.openTrack(name)` — registers a `TrackPublisher` for a
  track under the announced namespace. Idempotent insert is rejected.
- `TrackPublisher.send(payload)` — encodes one MoQ OBJECT_DATAGRAM and
  emits it to every currently-attached subscriber. Group id is fixed at
  zero per the audio-rooms NIP draft; object ids are monotonic.
- `TrackPublisher.close()` — sends SUBSCRIBE_DONE to attached
  subscribers, removes the track from its parent announce.
- `AnnounceHandle.unannounce()` — sends UNANNOUNCE on the wire, closes
  every registered publisher.
- `ErrorCode.TRACK_DOES_NOT_EXIST` and `SubscribeDoneStatus.{TRACK_ENDED,
  UNSUBSCRIBED}` constants for the SUBSCRIBE_ERROR / SUBSCRIBE_DONE
  values we emit.

Internal additions:
- `pendingAnnounces` keyed by namespace, mirroring the existing
  `pendingSubscribes` pattern.
- `announces` map tracking live publishers per namespace.
- `inboundSubscribers` + `publisherSubscribers` so the control-pump can
  fan an inbound SUBSCRIBE to the right TrackPublisher and so
  TrackPublisher.send can snapshot subscribers without per-OBJECT
  locking.
- Control pump now routes ANNOUNCE_OK / ANNOUNCE_ERROR (publisher-side
  ack) and inbound SUBSCRIBE / UNSUBSCRIBE (we're being asked to send
  OBJECTs, or stop). Unknown-track SUBSCRIBE replies with
  SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) so the peer doesn't hang.
- `close()` propagates session death into every AnnounceHandleImpl /
  TrackPublisherImpl so any in-flight `send` short-circuits cleanly.

All shared-state mutation is funneled through the existing `stateMutex`
(no `synchronized` — that's JVM-only and would break commonMain). The
session-wide `writeMutex` continues to serialize control-stream writes.

Tests (new in MoqSessionTest):
- announce → ANNOUNCE_OK happy path; UNANNOUNCE wire frame on close.
- ANNOUNCE_ERROR surfaces as MoqProtocolException.
- End-to-end: announce + openTrack + peer SUBSCRIBE → SUBSCRIBE_OK +
  three OBJECT_DATAGRAMs round-trip with intact group/object ids.
- send() returns false when no subscribers are attached (no buffering).
- Inbound SUBSCRIBE for unknown track under an announced namespace
  replies SUBSCRIBE_ERROR.

Verified: `:nestsClient:jvmTest` (12 tests, 0 failures) + `:quic:jvmTest`
(green) + `./gradlew spotlessApply` clean.
2026-04-26 03:11:33 +00:00
Claude f9aa762d9b feat(audio-rooms): M1 listener-only wire-up — Connect button + Connected chip + mute
Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.

UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
  above the existing hand-raise. State chip walks ResolvingRoom →
  OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
  reason inline with a Retry button. The 30312 `service` tag drives
  visibility — rooms hosted on non-nests servers show "Audio not
  available" and the rest of the stage UI continues to work.

ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
  `NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
  a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
  whenever the screen pushes a new speaker set via `updateSpeakers`.
  Audio-pipeline construction is injected via `decoderFactory` /
  `playerFactory` so a future desktop port can reuse the orchestration
  once Compose Desktop has WebTransport. Unit-tested with a fake
  `NestsListenerConnector` driving state transitions directly.

nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
  routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
  decode + network pipeline running so unmute is sample-accurate.

Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
  the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
  Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
  `:nestsClient`'s platform source sets and are bound by an
  Android-side `AudioRoomViewModelFactory` co-located with the screen.

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
2026-04-26 02:54:43 +00:00
Claude 4338e5e6c4 docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.

quic/plans/2026-04-26-quic-stack-status.md:
  Post-mortem of the original docs/plans/2026-04-22 plan. Documents
  what shipped vs what was estimated, the actual package layout (~8.5k
  LoC, 39 test files, 5 audit rounds), the crypto delegation surface
  (Quartz only — no BouncyCastle, no JNI), interop verification status
  (aioquic + picoquic; nests not yet), and known deferred items
  (STREAM retransmit, Initial-key discard, etc.).

nestsClient/plans/2026-04-26-audio-rooms-completion.md:
  Punch list to ship audio rooms end-to-end:
    M1 Listener wire-up in Amethyst UI
    M2 Multi-speaker audience UX
    M3 Foreground service for backgrounded playback
    M4 Manual interop pass against nostrnests.com
    M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
    M6 Capture → encode → publish pipeline
    M7 NestsSpeaker API
    M8 App polish (reconnect, leave cleanup)
    M9 Foreground service for speakers
  ~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.

Inline doc cleanup:
  * Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
    from active code; replaced with "today" or pointers to the
    completion plan
  * Removed "Kwik-based stub" references; QuicWebTransportFactory and
    surrounding docs now describe :quic as the production path
  * TlsClient header reflects non-null certificateValidator + the
    JdkCertificateValidator / PermissiveCertificateValidator split
  * SendBuffer header documents the best-effort no-retransmit mode
    explicitly (was hidden behind a "Phase L will fix this" note)
  * MoqMessage / MoqObject / MoqSession reflect listener-side as
    shipped + publisher-side as Phase M5

CLAUDE.md:
  * Module list now includes :quic and :nestsClient (was 5 modules,
    now 7)
  * Architecture diagram + sharing philosophy explain what each new
    module owns

No production behaviour changes; doc + comment-only edits. Tests green.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-26 01:38:48 +00:00
Claude 2f9a0a0e03 fix(quic): live interop against aioquic + round-3 audit fixes
🎉 First successful live-interop handshake against a real reference impl.
Drove our pure-Kotlin QUIC client at the aucslab/aioquic-http3-server Docker
container; HANDSHAKE COMPLETE with negotiated ALPN=h3 and full transport
parameter exchange:

  max_data=1048576, max_streams_bidi=128, max_streams_uni=128,
  idle_timeout=60000ms, max_datagram=65536

Every layer worked end-to-end over real UDP: packet codec, header
protection, AES-128-GCM AEAD, TLS 1.3 with X25519, CertificateVerify,
transport parameters, ALPN negotiation. The PermissiveCertificateValidator
+ InteropRunner main + Gradle :quic:interop task make this reproducible
in one command.

Round-3 audit fixes (8 critical bugs caught by 4 parallel reviewers):

1. feedDatagram coalesced-packet skip (RFC 9001 §5.5):
   `?: break` discarded all subsequent coalesced packets if any one of
   them failed to decrypt. Now uses peekHeader to advance over a failed
   packet, only breaking on a totally-unparseable header.

2. Receive-side flow-control enforcement:
   Parser was inserting STREAM frames without checking against the limit
   we advertised. A misbehaving peer could blow past initialMaxStreamDataX
   with no error; now triggers markClosedExternally with FLOW_CONTROL
   diagnostic.

3. Bounded incomingChannel (audit-2 finding finally addressed):
   QuicStream.incomingChannel was Channel.UNLIMITED — slow consumer +
   fast peer = unbounded heap growth. Now Channel(64), bounded by the
   per-stream receive limit. Combined with #2, memory growth is capped.

4. RetryPacket CID length validation:
   parse() didn't bounds-check dcidLen/scidLen against the RFC 9000 §17.2
   1..20 range. Hostile Retry with cidLen=255 → readBytes throws
   QuicCodecException to the caller (instead of returning null for silent
   drop). Added explicit `!in 0..20 → return null`.

5. HelloRetryRequest detection:
   No HRR check — we treated it as a regular ServerHello, derived an
   ECDHE on garbage, then failed AEAD downstream with a confusing error.
   Now checks ServerHello.random against the SHA-256("HelloRetryRequest")
   magic value and throws cleanly.

6. CancellationException handling in WT factory:
   Catch-all wrapped CancellationException as HandshakeFailed, breaking
   structured concurrency. Now rethrows ce after closing the driver.

7. InteropRunner scope leak on timeout:
   parentScope was never cancelled — orphaned coroutines kept the IO
   dispatcher alive after main exited. Now scope.cancel() runs
   unconditionally; small delay gives the driver-launched teardown a
   moment to flush before exit.

8. RFC 9220 §3.1 SETTINGS-before-CONNECT documented as known limitation:
   The strict requirement to wait for SETTINGS_ENABLE_WEBTRANSPORT=1
   before sending CONNECT requires more refactoring (the demux capturing
   peerSettings lives inside QuicWebTransportSessionState, which we don't
   build until after the request bidi opens). Tolerant servers (aioquic,
   quic-go) accept early CONNECT; documented for future strict-RFC fix.

All :quic:jvmTest + :nestsClient:jvmTest pass. Live aioquic interop verified
post-fix: handshake completes, transport parameters round-trip cleanly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 23:21:37 +00:00
Claude 8f5280c9c3 fix(quic): WebTransport peer-stream demux + flow-control + perf cleanup
Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.

WtPeerStreamDemux:
  Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
  streams by their leading varint(s):
    - HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
      SETTINGS frame for inspection (peerSettings property).
    - QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
      (we run with QPACK_MAX_TABLE_CAPACITY=0).
    - WebTransport unidirectional (0x54) + matching quarter session id →
      surfaces to app as a StrippedWtStream with the prefix stripped.
    - WebTransport bidi signal (0x41) + matching quarter session id → same.
    - Anything else → dropped per RFC 9114 §9.

  pollIncomingPeerStream is now @Deprecated; the recommended path is the
  incomingStrippedStreams flow on the session state. The nestsClient adapter
  now consumes the stripped flow and emits StrippedWtReadStreamAdapter
  through the existing WebTransportSession.incomingUniStreams API.

WT_CLOSE_SESSION decoder + CapsuleReader:
  Added stateful capsule reader that yields parsed WtCloseSession
  (errorCode + reason) from the CONNECT bidi. Wires into the future close
  notification path; not yet bound to status flip but the parser is in
  place.

Flow-control fixes from the audit:
  - Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
    streams now use initialMaxStreamDataUni instead of bidi-remote.
  - appendFlowControlUpdates picks per-direction window matching the
    stream's StreamId.kindOf.
  - MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
    Previously the writer emitted a fresh MAX_DATA on every outbound
    packet once the threshold was crossed (spam). Now: only when the new
    value strictly exceeds advertisedMaxData.

Stream iteration round-robin:
  Writer was iterating streamsLocked() in insertion order, starving streams
  created later under MTU pressure. Added streamRoundRobinStart counter on
  QuicConnection that advances after each drain.

SendBuffer alias defense:
  takeChunk's fast path used to return the head ByteArray reference directly
  when consuming the whole chunk — caller-owned byte arrays could be mutated
  in-place. Now always copies, since downstream encoders pass the bytes to
  AEAD.seal which assumes immutability.

UdpSocket cleanup:
  - readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
    65 KiB allocation was per-connection waste with no benefit.
  - Removed pointless synchronized(readBuf) — only the read loop touches it.

All :quic:jvmTest + :nestsClient:jvmTest pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:49:32 +00:00
Claude 94a3d32a6d fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.

QuicConnection / awaitHandshake hang fixes:
  - QuicConnectionClosedException + markClosedExternally(reason).
  - close() now also calls signalHandshakeFailed if !handshakeComplete.
  - QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
    (was: just flip status, leaving awaiters hanging forever).
  - QuicConnectionDriver.readLoop has a finally-block that calls
    markClosedExternally + wakeup so when the socket closes mid-handshake
    or the server closes uncleanly, awaitHandshake() throws instead of
    suspending forever.

QuicConnectionDriver.close() no longer cancels its own caller scope:
  Previously close() was suspend, called connection.close (acquires lock)
  → wakeup → scope.cancel → socket.close. If close() is invoked from
  inside the driver scope (e.g. by the WT factory's exception cleanup
  path), scope.cancel cancels the very coroutine running close, so
  socket.close may never run. close() is now non-suspend and dispatches
  the teardown onto parentScope.launch so the cancel never reaches its
  caller.

QuicWebTransportFactory.connect:
  - readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
    default 10s). Dead network → HandshakeFailed instead of forever-hang.
  - Whole post-handshake setup (open control + request streams + read
    response) now in try/catch that calls driver.close() on any unexpected
    exception. Previously a thrown SocketException between awaitHandshake()
    and the explicit non-2xx branch leaked the driver + UDP socket.

JdkCertificateValidator.validateChain authType:
  Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
  algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
  trust managers (RootTrustManager, NetworkSecurityConfig) gate
  algorithm-specific pinning rules on this string and may reject mismatched
  combos.

TlsExtension.decodeList bounds:
  Inner reads were unbounded against the extension-list end. A malicious
  server could claim totalLen=4 but encode an extension whose data length
  declared 1000, reading past the supposed extension-list end into
  trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
  asserts r.position ≤ end after each inner decode.

TlsClient KeyUpdate close-on-receipt:
  Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
  rotates keys and we keep using the old ones, AEAD opens silently fail
  → connection wedges. Until we implement rotation, KeyUpdate must throw
  so the QUIC layer closes cleanly with a fatal error instead of
  desynchronizing.

All :quic:jvmTest + :nestsClient:jvmTest pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:42:10 +00:00
Claude 368b8dd432 fix(quic): C3+C4+C9 + Tier-2 robustness from review
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  New Http3FrameReader buffers stream bytes and yields complete frames
  (DATA, HEADERS, SETTINGS, GOAWAY, Unknown). QuicWebTransportFactory
  drains the request stream after sending the Extended CONNECT request,
  feeds bytes through the reader, decodes the first HEADERS frame via
  QPACK, and pulls `:status`. Non-2xx → ConnectRejected. Without this,
  any 401/404/500 yielded a "connected" session that silently dropped.

  Tests: 5 new H3FrameReader tests covering SETTINGS, HEADERS round-trip,
  cross-push reassembly, unknown-type passthrough, multi-frame in one push.

C9 — flow-control enforcement + receive-side crediting
  - Send: per-stream `sendCredit` is now consulted before each takeChunk;
    bytes beyond `sendCredit - sentOffset` are held back. SendBuffer
    exposes `sentOffset` for the writer.
  - Receive: appendFlowControlUpdates() emits MAX_STREAM_DATA when the
    receive cursor crosses half the advertised window, and MAX_DATA at
    the connection level. Without this, peer windows close and any
    sustained transfer wedges silently.

Tier-2 cleanups (six items in one batch):
  - AckTracker.purgeBelow(): drop ranges below peer's largest_acked when
    we receive an ACK frame. Range list no longer grows unboundedly on
    long connections.
  - ReceiveBuffer adjacency edge: pull in the prior chunk when its
    endOffset exactly equals the new chunk's start. Previously perfectly-
    sequential receives starting at offset > 0 left adjacent chunks
    unmerged, growing the chunk list and overcounting bufferedAhead.
  - RetryPacket integrity-tag verify: constant-time compare instead of
    contentEquals.
  - ServerHello legacy_session_id_echo MUST be empty per RFC 8446 §4.1.3
    (we send empty); reject non-empty as a downgrade signal.
  - TLS state machine handles post-handshake NewSessionTicket and
    KeyUpdate at Application level — silently drop instead of throwing
    "unexpected post-handshake type" and tearing down the connection.

Tier-3 perf: SendBuffer chunked queue
  Replaced the O(N) copyOf-on-every-enqueue with an ArrayDeque<ByteArray>
  + headOffset cursor. Enqueue is now O(1); takeChunk peels at most one
  head chunk. Memory is bounded by the sum of outstanding writes instead
  of (sum)². For sustained MoQ stream writes of small chunks this drops
  from O(N²) memcpy to O(N).

All :quic:jvmTest + :nestsClient:jvmTest pass — every RFC 9001 Appendix A
vector still verifies bit-for-bit.

Remaining items deferred:
  Tier-2: incremental transcript hash (low impact: 4-5 calls per handshake
          over <10 KB), TLS HelloRetryRequest detection (we never send
          incompatible ClientHello today, server won't HRR).
  Tier-3: cipher reuse, Huffman lookup tree, UdpSocket selector, packet
          codec triple-allocation. None block live interop; revisit if
          measured RTT or CPU surfaces them.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:04:39 +00:00
Claude e250b76272 fix(quic): six critical correctness + security bugs from review
Synthesizes findings from four parallel layer reviews. Each fix here would
have broken or weakened live interop:

C1 — TlsClient stored cipher suite (was hardcoded)
  TlsClient.currentCipherSuite() always returned AES-128-GCM-SHA256, even
  when the server picked TLS_CHACHA20_POLY1305_SHA256. The QUIC layer would
  then install AES-GCM AEAD + AES-ECB header protection over a ChaCha20-
  derived secret → silent 1-RTT decrypt failure. Now stores the negotiated
  cipher from ServerHello and returns it.

C2 — AckTracker records the actual packet PN, not the largest received
  dispatchFrames() in QuicConnectionParser was passing
  state.pnSpace.largestReceived to the ACK tracker. With two coalesced
  packets in one datagram, only the larger PN was ever tracked → server
  retransmits the smaller forever. Plumb the parsed packet's PN through
  dispatchFrames and feed it to receivedPacket(). Also always record (even
  for non-ack-eliciting packets) so the peer's loss recovery sees a
  contiguous picture.

C5 — bounds-check every readVarint().toInt() length in frame decode
  CRYPTO, STREAM (LEN), CONNECTION_CLOSE reason, DATAGRAM_LEN, and ACK
  range count all read a 62-bit varint, truncate to Int, and pass straight
  to readBytes / repeat. A hostile peer could send length=2^62-1 → crash or
  multi-GB allocation. Added boundedLength() + boundedRangeCount() helpers
  that reject if value < 0 or > remaining.

C6 — frame type dispatch uses readVarint, not readByte
  RFC 9000 §12.4 specifies frame types as varints. We were reading a single
  byte, so any extension frame type ≥ 0x40 (e.g. ACK_FREQUENCY 0xAF) would
  be mis-dispatched. All current types are < 64 so the 1-byte form matches
  the 1-byte varint, but the change is forward-compatible.

C7 — CertificateValidator required (no silent skip)
  Both QuicConnection and TlsClient previously had `validator: ... = null`
  defaults. A misconfigured caller would silently accept any server's
  certificate. Removed the defaults; null is now an explicit opt-in for
  in-process loopback tests. Added JdkCertificateValidator backed by the
  platform / JDK system trust store with proper SAN-based hostname check
  and signature verification for ECDSA / RSA-PSS / RSA-PKCS1 / Ed25519.
  QuicWebTransportFactory uses it by default.

C8 — thread-safety on connection state
  QuicConnection.streams, pendingDatagrams, nextLocalBidiIndex/UniIndex
  were mutated from the driver loops and from app coroutines without
  synchronization → ConcurrentModificationException waiting to happen.
  Moved the mutex onto QuicConnection itself; the driver wraps feed/drain
  with `connection.lock.withLock { ... }`, public mutators became suspend
  and acquire the same lock. Internal helpers used by feed/drain are
  marked `Locked` to make the precondition explicit.

  Also replaced the `delay(2)` send-loop polling with a CONFLATED
  `Channel<Unit>` wakeup — app writes (queueDatagram, openBidiStream,
  stream write via the WT adapter) call `driver.wakeup()`. Idle CPU
  drops to zero between packets.

  awaitHandshake() replaces the busy-poll over `conn.status` in
  QuicWebTransportFactory.connect — backed by a CompletableDeferred that
  the TLS listener completes on onHandshakeComplete() or fails on a torn
  down read loop.

Tests: full :quic:jvmTest and :nestsClient:jvmTest suites pass — every
RFC 9001 Appendix A vector still verifies bit-for-bit.

Remaining critical work (in progress, separate commits):
  C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  C9    — flow-control enforcement + MAX_STREAM_DATA crediting

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:58:45 +00:00
Claude 46742636c7 feat(quic): Phase L — wire QuicWebTransportFactory into nestsClient
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.

- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
  QuicConnectionDriver + the CONNECT bidi stream id and exposes
  open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
  close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
  are pushed onto each new stream automatically. close() emits a
  WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
  KwikWebTransportFactory) drives the full open sequence:
    1. UDP connect + QuicConnection.start
    2. wait until handshake completes (Status.CONNECTED)
    3. open H3 control uni-stream with stream-type 0x00 + the
       SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
       ENABLE_WEBTRANSPORT=1)
    4. open the Extended CONNECT bidi: HEADERS frame carrying
       :method=CONNECT, :protocol=webtransport, :scheme=https,
       :authority, :path, optional Authorization: Bearer
    5. wrap the connection + driver + connect stream id in a
       WebTransportSession adapter that the existing nestsClient MoQ +
       audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
  :amethyst, :commons, or the audio pipeline changes — the moment connect()
  returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
  class name, comment-style cleanup in TlsConstants and LongHeaderPacket.

Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:03:54 +00:00
Claude 2d541c6fd4 feat(quic): Phase A — module foundations
Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.

Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.

- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
  source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
  MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
  backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
  tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
  the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
  jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).

All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:19:02 +00:00
davotoula 999184cced optimise imports 2026-04-24 18:15:58 +02:00
Claude c07f7baa14 docs(nestsClient): Phase 3b-2 Kwik integration plan in stub
Phase 3b-2 attempt. The honest version: I do not have network access
to verify Kwik's current Maven coordinates or test against a live
nests server, so committing speculative QUIC + Extended CONNECT code
risks breaking the build for everyone else without giving us
audible-audio-on-device confidence in return.

What I did instead: expanded the docstring on KwikWebTransportFactory
into a full integration playbook that the next person picking this
up can execute directly. It covers:

- Maven coordinates to verify (`tech.kwik:kwik-core` + `tech.kwik:flupke`,
  with `net.luminis.quic:kwik` as the legacy fallback group).
- The exact `gradle/libs.versions.toml` + `nestsClient/build.gradle.kts`
  edits to drop in once coords are confirmed.
- Step-by-step handshake sequence with the right HTTP/3 setting IDs:
  * SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441, 0x08)
  * SETTINGS_ENABLE_WEBTRANSPORT=1 (WT-H3 draft, 0x2b603742)
  * SETTINGS_H3_DATAGRAM=1 (RFC 9297, 0x33)
- Extended CONNECT pseudo-header set, including the legacy
  `sec-webtransport-http3-draft02 = 1` for older server compat.
- WebTransport stream-type prefix bytes (0x41 for client bidi, 0x54
  for client uni) + WT capsule type for graceful close (0x2843).
- Test strategy: validate against local nests-rs first, then the
  production `nostrnests.com`.
- Open questions (Flupke Extended CONNECT maturity, draft churn,
  dev-only self-signed-cert support, Android API surface).

Behavior unchanged: connect() still throws WebTransportException
with Kind.NotImplemented and a message pointing at this docstring.

When the real implementation lands, no upstream caller needs to
change — the AudioRoomConnectionViewModel from Phase 3d-3 will
auto-light up the connection chip from "Failed: NotImplemented" to
"Connected" and start producing audio through the Phase 3d-1
pipeline that's already wired.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:41:56 +00:00
Claude b62e3dd0ec feat(nestsClient): MoQ ANNOUNCE family + Opus encoder + AudioRecord capture
Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ
control messages and the Opus encode + microphone capture pieces a
speaker needs. The host-grants-speaker UI flow is deferred — that's
multi-screen UX that should be designed before being implemented.

commonMain (nestsclient.moq):
- 5 new MoqMessageType entries with codec round-trip + tests:
  * Announce (0x06): publisher offers a track namespace.
  * AnnounceOk (0x07): subscriber acknowledges.
  * AnnounceError (0x08): subscriber rejects with error code +
    reason phrase.
  * Unannounce (0x09): publisher withdraws a previously-announced
    namespace.
  * SubscribeDone (0x0B): publisher tells the subscriber no more
    objects are coming for this subscription, with stream count and
    reason.

commonMain (nestsclient.audio):
- New `OpusEncoder` interface — symmetric to `OpusDecoder`, one
  instance per outgoing track since Opus state is per-stream.
- New `AudioCapture` interface — `start()`, `readFrame()` returns a
  PCM frame or null when stopped, `stop()` releases the mic.

androidMain (nestsclient.audio):
- `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder
  variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms
  frames. Drains output queue per encode call.
- `AudioRecordCapture` — wraps `AudioRecord` from
  `MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's
  echo-cancellation + noise-suppression filters apply when available.
  Reads exactly one PCM frame per readFrame() call, retries on
  underrun, throws AudioException(DeviceUnavailable) on permission/
  resource failures.

commonTest:
- `AnnounceCodecTest` — 7 cases covering each message round-trip,
  concatenated decode in sequence, and a guard against accidentally
  reordering MoqMessageType enum codes.

Permission already declared:
- `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no
  manifest change needed.

What this does NOT include:
- A publisher loop class (analogous to AudioRoomPlayer for publish
  direction) — can be added when the speak-button wiring lands.
- The host-grants-speaker UI — needs design input.
- STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams
  cover the listener happy-path; streams add reliability that nests
  may or may not require.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:33:55 +00:00
Claude c1355f1dd8 feat(nestsClient): NestsListener facade + connect orchestrator
Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.

commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
  `subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
  speaker's track as `["nests", <roomId>]` with the speaker's pubkey
  hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
  * Idle
  * Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
  * Connected(roomInfo, negotiatedMoqVersion)
  * Failed(reason, cause?)
  * Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
  connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
  roomId, signer, supportedMoqVersions)` — walks the three handshake
  steps. Each failure short-circuits to Failed with a clear reason
  string and the underlying cause attached; transport is torn down on
  partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
  free of a URL-library dependency. Handles default-port stripping,
  rejects userinfo, preserves query strings.

commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
  * Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
    asserts state lands on Connected with the right roomInfo +
    negotiated version, and verifies the transport saw the parsed
    authority/path/bearer.
  * resolveRoom failure short-circuits without touching transport.
  * Transport handshake failure short-circuits with the right kind in
    the reason.
  * Malformed endpoint URL short-circuits.
  * parseEndpoint covers default-port stripping, explicit port,
    pathless URL, query string preservation.

This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:

  state = MutableStateFlow(NestsListenerState.Idle)
  listener = connectNestsListener(...)
  for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
      AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)

…and audio plays.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:21:38 +00:00
Claude 6e6fc4cabb feat(nestsClient): listener audio pipeline (Opus decode + AudioTrack)
Phase 3d-1 of the Clubhouse/nests integration. Adds the listener-side
audio pipeline that turns a SubscribeHandle's Flow<MoqObject> into
audible PCM through Android's MediaCodec + AudioTrack. Encoder /
AudioRecord (speaker publishing) lands in Phase 4.

commonMain (nestsclient.audio):
- `AudioFormat` constants — 48 kHz mono signed-16-bit, 20 ms frames
  (960 samples), matching the nests Opus profile.
- `OpusDecoder` interface — stateful per-track decoder.
- `AudioPlayer` interface — start/enqueue/stop, suspend on backpressure.
- `AudioRoomPlayer` — wires a Flow<MoqObject> through OpusDecoder into
  AudioPlayer. Decoder errors are surfaced via an `onError` callback
  but don't tear down the loop (one bad packet shouldn't kill the
  room); player errors are fatal. play()/stop() are single-shot per
  instance, with stop() idempotent and double-release-safe.
- `AudioException` with three canonical kinds (DecoderError,
  DeviceUnavailable, PlaybackFailed).

androidMain:
- `MediaCodecOpusDecoder` — wraps `MediaCodec("audio/opus")` with the
  RFC 7845 §5.1 Opus identification header in csd-0 and zeroed
  pre-skip / seek-pre-roll in csd-1 / csd-2. Drains the output queue
  per-packet, handles INFO_OUTPUT_FORMAT_CHANGED gracefully.
- `AudioTrackPlayer` — wraps `AudioTrack` in MODE_STREAM with USAGE_
  VOICE_COMMUNICATION / CONTENT_TYPE_SPEECH so the OS treats audio-
  room playback like a phone call (call-volume rocker, ducks
  notifications). Buffer = 4× minimum so the producer can fall behind
  ~80 ms (roughly the WebTransport datagram jitter on mobile).

Tests (commonTest, fake-based):
- `AudioRoomPlayerTest` — 7 cases covering happy-path decode +
  enqueue, decoder failure with continued loop, stop idempotency,
  double-play rejection, play-after-stop rejection, empty-PCM
  skip-enqueue, and live channel-backed flow.

The real MediaCodec / AudioTrack code is thin glue against Android
APIs and is validated manually on device — no Robolectric here, the
seams are the Fake* doubles.

Next: Phase 3d-2 wires NestsClient end-to-end (HTTP auth → MoqSession
listener subscribe → AudioRoomPlayer) plus the Amethyst-side
AudioRoomViewModel. After that the only remaining pure-MoQ work is
the Kwik handshake (Phase 3b-2).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:12:25 +00:00
Claude b091b50fe6 feat(nestsClient): MoQ session pump + subscribe API
Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession
from a one-shot SETUP runner to a real concurrent session: the
control-stream and datagram pumps run on a caller-supplied scope
after the handshake, and a public subscribe()/unsubscribe() API
delivers per-track OBJECT_DATAGRAMs as a Flow<MoqObject>.

commonMain (nestsclient.moq):
- MoqSession now takes a `pumpScope: CoroutineScope` so callers can
  bind the pumps to their lifecycle (test backgroundScope, viewmodel
  scope, etc.).
- After setup() succeeds, two background coroutines start:
  * Control-stream pump — buffers chunks across multiple writes,
    decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR
    to the matching CompletableDeferred, drops malformed frames
    without killing the session.
  * Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the
    matching per-track sink keyed by track_alias.
- `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE,
  awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR
  with the publisher's reason), and returns a SubscribeHandle whose
  `objects` flow emits inbound OBJECT_DATAGRAMs.
- `unsubscribe(subscribeId)` is idempotent; second call is a no-op.
  close() cancels pumps + tears down all in-flight subscribes cleanly.
- Per-track sink uses a bounded Channel with DROP_OLDEST overflow
  rather than MutableSharedFlow, so objects buffered between
  subscribe() returning and the consumer attaching are preserved
  (SharedFlow with replay=0 silently drops pre-subscription emissions).

transport (FakeWebTransport):
- Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()`
  during setup followed by a long-running pump `.collect{}` works on
  the same channel without the first call closing it.

Tests:
- Updated existing setup tests for the new `pumpScope` parameter.
- New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order`
  end-to-end: client subscribes, raw server peer (no MoqSession to
  avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams,
  client's flow yields all three in order with correct payloads.
- `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`.
- `datagrams_for_unknown_track_alias_are_dropped_silently` ensures
  the pump doesn't crash on orphan datagrams and the session stays
  closeable.

This completes the pure-Kotlin MoQ work. Remaining 3.x phases all
touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the
real WebTransport handshake (Phase 3b-2 with Kwik).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 06:58:52 +00:00
Claude d65ab7b616 feat(nestsClient): MoQ SUBSCRIBE family + OBJECT_DATAGRAM codecs
Phase 3c-2 of the Clubhouse/nests integration. Extends the MoQ
control-plane codec with the subscribe lifecycle messages (SUBSCRIBE,
SUBSCRIBE_OK, SUBSCRIBE_ERROR, UNSUBSCRIBE) and adds the
OBJECT_DATAGRAM wire format the listener uses to receive low-latency
Opus audio. Pure codec layer — no session-pump or network changes;
the subscribe/object delivery Flow arrives in Phase 3c-3.

commonMain (nestsclient.moq):
- New message types in `MoqMessageType`: Subscribe (0x03),
  SubscribeOk (0x04), SubscribeError (0x05), Unsubscribe (0x0A).
- New data classes in MoqMessage.kt:
  * `TrackNamespace` — tuple of byte strings, with `of(vararg String)`
    helper for the common UTF-8 case.
  * `SubscribeFilter` enum. Phase 3c-2 supports LatestGroup /
    LatestObject; AbsoluteStart/AbsoluteRange throw at construction
    (they need extra wire fields we'll add when nests needs them).
  * `Subscribe`, `SubscribeOk`, `SubscribeError`, `Unsubscribe` data
    classes. `SubscribeOk` validates contentExists + largest-id
    coupling at construction.
- New codecs in `MoqCodec` (namespace tuple + all four messages).
  Unknown filter codes on the wire are rejected.
- `MoqObject` data class + `MoqObjectDatagram` encode/decode for the
  OBJECT_DATAGRAM wire format (track_alias, group_id, object_id,
  publisher_priority, status, payload).

Tests:
- `SubscribeCodecTest` — round-trips for each message with multi-
  segment namespaces + parameters, SUBSCRIBE_OK with and without
  contentExists, concatenated decode in sequence, unknown-filter
  rejection, construction-time validation.
- `MoqObjectDatagramTest` — Opus-sized payload round-trip, zero-
  length payload with OBJECT_DOES_NOT_EXIST status, 8-byte-varint
  boundary coverage, truncated datagram rejection, out-of-range
  priority rejection.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:26:53 +00:00
Claude 37e24ce4f3 feat(nestsClient): MoQ varint + SETUP handshake codec
Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport
control-plane codec (draft-ietf-moq-transport) and a session wrapper
that runs the SETUP handshake over any WebTransportSession. Purely
commonMain + fully tested end-to-end against FakeWebTransport — no
network or Kwik dependency required.

commonMain (nestsclient.moq):
- `Varint` — QUIC variable-length integer codec per RFC 9000 §16.
  Encode/decode/size, with typed truncation handling (decode returns
  null so callers can buffer more and retry).
- `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked
  reader used by the message codec.
- `MoqCodecException` — typed error for malformed frames.
- `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`.
  Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types
  0x40 / 0x41).
- `MoqCodec.encode/decode` — wraps payload with `type (varint) +
  length (varint)`. Rejects unknown types and trailing bytes inside a
  declared payload window.
- `MoqSession.client/server` — attaches to a WebTransportSession and
  runs the CLIENT_SETUP / SERVER_SETUP handshake with version
  negotiation + configurable timeout.
- `MoqVersion` constants for draft-11 and draft-17.

Tests:
- `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte),
  boundary round-trips, negative/overflow rejection, short-buffer
  returns null, bytesConsumed accuracy.
- `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and
  multi-param), multi-version negotiation, exact wire layout for
  ServerSetup(1L), truncated-frame returns null, concatenated frames
  decode with offset, unknown type rejected, trailing bytes rejected,
  zero-length parameter values allowed.
- `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both
  sides happy path, client falling back to older version, server
  rejecting when no version overlap + client timing out cleanly).

SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in
Phase 3c-2 on top of the same MoqSession.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:21:15 +00:00
Claude 4ead4ccd5c feat(nestsClient): WebTransport abstraction + fake + Kwik stub
Phase 3b-1 of the Clubhouse/nests integration. Lands the
[WebTransportSession] abstraction the MoQ layer (Phase 3c) will code
against, so MoQ framing + tests can develop in parallel with the real
Kwik-based transport integration (deferred to Phase 3b-2).

commonMain:
- `WebTransportSession` — bidi/uni stream access, datagrams, close.
- `WebTransportBidiStream` / `WebTransportReadStream` /
  `WebTransportWriteStream` — minimal read/write surface.
- `WebTransportFactory.connect(authority, path, bearerToken)` for
  opening sessions.
- `WebTransportException(kind, ...)` with four canonical failure modes
  (HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI
  code doesn't need to know about library-specific exceptions.
- `FakeWebTransport.pair()` — in-memory, fully-wired client/server
  pair for unit-testing MoQ framing without touching a real QUIC stack.

jvmAndroid:
- `KwikWebTransportFactory` stub that throws
  `WebTransportException(NotImplemented)` at `connect()`. Doc spells
  out the handshake sequence (QUIC dial → H3 SETTINGS →
  `:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so
  Phase 3b-2 can drop the real implementation in without touching
  callers.

Tests:
- `FakeWebTransportTest` — datagram round-trip both directions,
  bidi-stream write visible on peer side, close() flips isOpen.
- `WebTransportExceptionTest` — kind + message + cause preserved.
- `KwikWebTransportFactoryTest` — `connect()` currently fails with the
  NotImplemented sentinel, so Phase 3b-2 can replace this test when
  the real handshake lands.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:10:28 +00:00
Claude 933b522273 feat(nestsClient): NIP-98 auth + nests room-info client
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.

Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
  Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
  `Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
  fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
  `<serviceBase>/<roomId>` with the signed NIP-98 header and returns
  the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
  shared between Android and desktop JVM.

Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
  unknown-field tolerance, missing-endpoint rejection, and URL
  construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
  base64 back through Quartz's JacksonMapper, and asserts the
  signature verifies and the url+method tags bind correctly.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 02:58:24 +00:00