Commit Graph

12870 Commits

Author SHA1 Message Date
Claude 1b0740d4a8 feat(nests): emit catalog jitter hint matching Opus frame duration
hang.js's encoder publishes a `jitter` field (ms) in every audio
rendition; the watcher uses it to size its jitter buffer. Without it
the watcher falls back to a built-in default — works in practice today
but means our publishes deviate from the canonical hang shape and the
deviation could surface as audible buffer-sizing differences on a
future watcher revision that takes the field as required.

Add `jitter: 20` to MoqLiteHangCatalog.AudioRendition (matching the
Opus frame cadence: 960 samples at 48 kHz = 20 ms; same value hang.js
hard-codes at `js/publish/src/audio/encoder.ts:#runConfig`). Updates
the byte-exact MoqLiteHangCatalogTest assertion and the parallel
inline-literal in RoomSpeakerCatalogTest so both sides stay pinned.

The commons-side `RoomSpeakerCatalog` parser doesn't surface the new
field — `JsonMapper`'s `ignoreUnknownKeys = true` lets the watcher
ingest it silently. Adding a typed `jitter` field there is a
forward-compat task; not needed for interop with hang today.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:18:54 +00:00
Claude ff9bb25921 refactor(nests): replace hand-built catalog JSON with typed MoqLiteHangCatalog
The two SPEAKER_CATALOG_JSON call sites (MoqLiteNestsSpeaker for the
non-reconnecting path, ReconnectingNestsSpeaker for the JWT-refresh hot-
swap path) both built the manifest as a `\\` + `\"` + `+`-concatenated
string literal. One missed escape and the watcher's parser fails
silently — the broadcast becomes invisible to hang.js with no
indication of why. The two literals had also drifted independently in
review (same content today; nothing prevents drift tomorrow).

Replace both with `MoqLiteHangCatalog.opusMono48k(track).encodeJsonBytes()`,
a Serializable data class that encodes via kotlinx.serialization with
`encodeDefaults = false` + `explicitNulls = false` pinned (so a future
global default-flip can't surface `"bitrate": null` on hang.js's
parser).

The new type lives in :nestsClient because :nestsClient does not depend
on :commons and the parser-side `RoomSpeakerCatalog` (in :commons)
isn't reachable from the publish path. The two classes target the same
wire shape independently; a byte-exact assertion in the new
MoqLiteHangCatalogTest plus the existing RoomSpeakerCatalogTest's
inline-literal round-trip together pin both sides — desync on either
end fails one of the two tests immediately.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:13:47 +00:00
Claude aa37a931e1 test(okhttp): widen SurgeDns stale-refresh TTL to defeat double-expiry race
The 1ms positiveTtlMs let the second refreshed entry expire again
between the post-refresh assertion and the cache-hit lookup that
followed, triggering an extra background refresh that broke the
calls=2 invariant on slow CI runners. 100ms TTL with sleep(150)
keeps the expiry boundary clearly outside the assertion window.
2026-05-06 03:48:57 +00:00
Claude 5310b3f574 Merge remote-tracking branch 'origin/main' into claude/fix-nests-audio-receiver-HCgOY 2026-05-06 03:18:09 +00:00
Claude 5b7e347ac0 feat(nests): align moq-lite catalog with kixelated/hang shape
Switch RoomSpeakerCatalog to the canonical hang catalog format
(camelCase audio.renditions[<track>] with container.kind), wrap
audio frames in the legacy varint(timestamp_us) prefix on publish,
and strip it on subscribe. Also reply to inbound Probe/Fetch/Session
bidis with a bitrate hint or clean FIN instead of hanging.
2026-05-06 03:16:01 +00:00
Claude 5389b32e52 feat(nests): keep catalog.json published across JWT refresh
ReissuingBroadcastHandle's hot-swap iteration now opens its own
catalog.json publisher on every fresh session, pushes the manifest
once, and launches the same 2 s republish loop that
MoqLiteNestsSpeaker.startBroadcasting uses for the legacy
non-reconnecting path. Each iteration tears down the prior session's
catalog publisher + republisher AFTER the new pair is installed, so
watchers see at most a brief overlap rather than a gap when the
600 s moq-auth bearer triggers a session recycle.

Without this, the catalog goes silent the moment the wrapper recycles
the underlying session; any watcher that attaches AFTER the recycle
finds nothing to subscribe to even though the audio path is hot-
swapped seamlessly. With it, both audio and catalog survive every
JWT refresh.

Catalog teardown is also added to ReissuingBroadcastHandle.close()
because — unlike the audio publisher which broadcaster.stop() handles
internally — the catalog has no broadcaster wrapper.
2026-05-06 01:06:36 +00:00
Claude babf7623d8 feat(nests): publish catalog.json so browsers can discover broadcasts
Two-phone Amethyst sessions stream Opus frames just fine on the wire,
but the kixelated/moq browser watcher (the canonical web reference) sees
nothing — because we never published a catalog.json track. moq-lite
watchers discover a broadcast's tracks by subscribing to the
`catalog.json` track on the broadcast suffix and parsing the JSON
manifest. Without it the watcher has nothing to subscribe to.

Two parts:

1. MoqLiteSession.publish now supports multiple tracks per session,
   sharing one broadcast suffix. The previous "one publisher per
   session" invariant rejected the second publish() with
   "already publishing", forcing a single track per session.

2. MoqLiteNestsSpeaker.startBroadcasting now opens both an audio
   publisher AND a catalog publisher, pushes the manifest once, and
   launches a 2s republish loop so late-attaching watchers don't wait
   indefinitely for the next refresh.

The manifest shape mirrors RoomSpeakerCatalog (kotlinx.serialization
data class on the listener side):

    {"version":1,"audio":[{"track":"audio/data","codec":"opus",
                           "sample_rate":48000,"channel_count":1}]}

Hard-coded for now since OpusEncoder is fixed at 48kHz mono.

Hot-swap path (ReconnectingNestsSpeaker session refresh) is NOT yet
wired to recycle the catalog publisher — acceptable trade-off for a
diagnostic landing; follow-up will move the catalog republisher into
HotSwappablePublisherSource so it survives JWT refreshes.
2026-05-06 00:56:43 +00:00
Vitor Pamplona 0874706b31 Merge pull request #2742 from vitorpamplona/claude/fix-eventstore-expiration-test-AbtSm
test(quartz): widen NIP-40 expiration buffer to outlive insert race
2026-05-05 20:54:39 -04:00
Claude d6be9e4574 test(quartz): drive nip40 expiration test through projection directly
Replace the wall-clock dance with a direct EventStoreProjection
construction wired to a controllable nowProvider. Both events are
inserted with expirations safely in the future so the SQL
reject_expired_events trigger never fires; the test then bumps the
fake clock and replays the StoreChange.DeleteExpired the observable
would emit after a sweep. Tests the projection's reaction to the
sweep signal — which is what this case was always about — without a
6s real-time delay or a flake window. Full SQL sweep behavior
remains covered by ExpirationTest.testDeletingExpiredEvents.
2026-05-06 00:50:30 +00:00
Claude 5f4d8ba747 test(quartz): widen NIP-40 expiration buffer to outlive insert race
The reject_expired_events trigger checks NEW.expiration <= unixepoch()
at insert time, so signing/insert latency on a slow runner could push
unixepoch() to time+1 before the row was committed, raising
SQLiteException at runBlocking entry. Bumping the short event to
time+5 with a matching delay(6000) mirrors
ExpirationTest.testDeletingExpiredEvents and gives the trigger room
to breathe without changing the behavior under test.
2026-05-06 00:42:14 +00:00
Vitor Pamplona 44f3776e86 Merge pull request #2741 from vitorpamplona/claude/investigate-build-size-458NE
Optimize CI builds and reduce APK size with Gradle caching and font subsetting
2026-05-05 20:39:07 -04:00
Claude bedf21f57e chore(nests): auto-enable NestsTrace recorder in debug builds
Hook into Amethyst.onCreate behind the existing isDebug check so
the JSONL session-trace recorder lights up the moment a debug APK
launches — no per-room toggle to find, no rebuild needed to flip
the flag, no extra step on top of the existing logcat capture
flow.

Release builds are unaffected: setRecording() is gated by
`if (isDebug)`, so the volatile flag stays false and every emit
site short-circuits at the field-load + branch.

Capture from a connected receiver phone with:

  adb logcat -c
  adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl

The `-v raw` formatter strips the `D NestsTraceJsonl:` prefix so
the file is valid JSONL ready for the (planned)
TraceReplayingTransport to feed back through MoqLiteSession +
NestViewModel for headless cliff-scenario regression tests.
2026-05-06 00:22:43 +00:00
Claude a86f19f069 feat(nests): NestsTrace recorder for replayable session captures
Adds an opt-in JSONL event recorder behind every receiver-side
moq-lite + NestViewModel decision point so a real two-phone
production session can be captured and (in a follow-up) replayed
through the unmodified pipeline as a unit test. Step 1 of the
"capture-then-replay" plan from the prior conversation.

Off by default — production release builds that never call
`NestsTrace.setRecording(true)` pay one volatile-load + branch per
emit site. The fields-builder lambda doesn't run on the disabled
path, so call sites can do non-trivial string concat freely.

Output goes to logcat under tag `NestsTraceJsonl`. Capture with:

  adb logcat -c
  adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl

The `-v raw` formatter strips the `D NestsTraceJsonl:` prefix so
the captured file is valid JSONL ready for replay tooling.

Schema per line: `{"t_ms":N,"kind":"K", ...kind-specific fields}`,
where `t_ms` is milliseconds since `setRecording(true)` was first
called. Field names are lower_snake_case for stability across
clients.

Wired into 13 call sites this commit (matched 1:1 with existing
NestRx/NestTx human log lines so the trace and the log line stay
adjacent and the diff stays small):

`MoqLiteSession`:
  - announce_bidi_opened (per session.announce call)
  - announce_pump_emit (per Active/Ended received on a bidi)
  - announce_bidi_ended_naturally / announce_bidi_threw
  - subscribe_send / subscribe_ok / subscribe_drop
  - subscribe_bidi_exited
  - announce_watch_update / announce_watch_ended_closing_subs
  - uni_pump_started
  - group_header / group_fin / group_threw

`NestViewModel`:
  - vm_observe_announce (per ann emission)
  - cliff_tick (every CLIFF_DIAG_LOG_EVERY ticks: active + announced
    sets + per-pubkey lastFrameAt elapsed-ms)
  - cliff_recycle (when the detector forces a recycleSession())

Anonymisation: pubkeys + track names recorded verbatim — they're
already in the existing `NestRx`/`NestTx` log lines the user is
sharing. Frame payloads are NEVER recorded, only sizes — audio
content can't leak through a trace dump.

Tests: NestsTraceTest (9 cases) — exhaustive jsonStr/jsonArrStr
quoting + setRecording state-machine + emit-lambda-noop-when-
disabled coverage. The `emit` log-output side itself is untestable
in commonTest because `Log.d` writes to a platform actual; the
schema correctness we DO want to pin (a JSON syntax bug at one of
the 13 call sites would silently break replay) is covered by the
quoting helpers.

CliffDetectorTest 12/12 + MoqLiteSessionTest 11/11 still pass —
the trace wiring is purely additive next to existing log statements.

Follow-up: a `TraceReplayingTransport` reading these JSONL files
back through `WebTransportSession` to drive end-to-end regression
tests for the cliff-recovery scenarios captured from production.
2026-05-06 00:17:43 +00:00
Claude 19588be84b build(perf): trim build outputs for hooks, CI, and the shipped font
Headline numbers from a fresh `./gradlew assemble`:
- :commons material_symbols_outlined.ttf 11M -> 409K (subset to the 210
  codepoints actually referenced from MaterialSymbols.kt)
- :amethyst per-ABI APKs no longer built in CI (-PdisableAbiSplits=true);
  ~600M of stripped_native_libs intermediates skipped per run

Changes:
- .git-hooks/pre-push runs only :amethyst:testPlayDebugUnitTest plus jvmTest
  on the KMP modules instead of `./gradlew test`, which was compiling all six
  amethyst variants (play/fdroid x debug/release/benchmark)
- amethyst/build.gradle adds three opt-in fast-build flags:
    -PdisableAbiSplits=true       skip per-ABI APK splits
    -PdisableUniversalApk=true    skip the universal APK output
    -Pamethyst.skipMapping=true   disable R8 (release+benchmark)
  Defaults are unchanged; release pipelines must not set skipMapping.
- .github/workflows/build.yml uses gradle/actions/setup-gradle@v4 with
  cache-read-only on PRs / cache-write on main, drops --no-daemon, collapses
  the Android job to a single Gradle invocation (lint + focused debug unit
  tests + assembleBenchmark with -PdisableAbiSplits=true), and globs both
  APK naming patterns for the upload step.
- tools/material-symbols-subset/{subset.sh,README.md} regenerates the font
  from upstream + MaterialSymbols.kt; run after adding/removing icons.

Verified: ./gradlew :amethyst:help with all three new -P flags parses cleanly,
and ./gradlew :commons:jvmJar --rerun-tasks succeeds with the subsetted font
(commons-jvm jar shrinks from ~13M to 2.9M).

https://claude.ai/code/session_01YSmkagXXN5AwGcY4upiUh1
2026-05-05 22:49:43 +00:00
Claude a36ccb5692 fix(nests): close the relay-cliff at the source — framesPerGroup 10→50, cliff-threshold 4s→2.5s
Two-phone production logs at commit 6e4df4a (run 18:37) showed the
listener-side cliff still hits at ~16 s of streaming (51 groups
delivered, sender continued to seq=80 = ~6 s of audio lost at the
tail before the receiver recycled). Recycling alone can't fix this
— it only spaces the pain out. The user wants no audio drops mid-
transmission, period. The leverage is to keep the relay's
per-subscriber forward queue from filling at all.

The cliff is rate-limited per uni-stream-creation, not per byte or
per frame — moq-rs's `serve_group` task pool can't drain new
`open_uni().await`s fast enough at high stream rates. Cutting the
stream creation rate cuts the cliff window proportionally:

  framesPerGroup |  streams/sec @ 50 fps  |  observed cliff window
  ---------------+------------------------+----------------------
       1         |       50               |   ~3 s (commit d6517cf)
       5         |       10               |   ~13 s
      10         |        5               |   ~16 s (commit 6e4df4a)
      50         |        1               |   not reached in
                                              `fpg-all` sweeps
     100         |      0.5               |   never observed

Bumping default to 50 (1 s of audio per uni stream, 1 stream/sec)
puts us in the regime where the relay's queue does not measurably
fill. `fpg-all` (100 frames in a single group) routinely delivers
100/100 in production sweeps, so 50 is well clear of the cliff
edge. We pick 50 not 100 because:

  - Late-join gap is bounded by group size — a listener joining
    mid-broadcast waits one group boundary for the first frame.
    1 s is within the ~250 ms AudioTrack jitter buffer +
    `ROOM_PLAYER_PREROLL_FRAMES = 5` audio-buffer envelope; 2 s
    starts to be perceptible.
  - QUIC stream RST drops the whole group on full reset. 1 s of
    skip on rare RST is bearable; 2 s is a noticeable seam.
  - Sender encode latency stays at 1 s — no worse than the
    natural buffering audio rooms already do for jitter.

Belt-and-suspenders: also tighten the cliff-detector's silence
threshold from 4 s to 2.5 s. Healthy streams now arrive every ~1 s
(one `drainOneGroup` FIN per group), so 2.5 s of silence is
unambiguously past two missed group cycles. Catches a still-
possible cliff event ~1.5 s earlier, shaving the audible gap from
~6 s (4 s detection + 2 s reconnect) to ~4.5 s if recovery is
needed at all.

Tests: existing `framesPerGroup`-explicit interop scenarios
(`fpg5`, `fpg20`, `fpg-all`) are unaffected — they pass an
explicit value and don't read the default. `CliffDetectorTest`
boundary cases updated to the new 2.5 s threshold;
`returnsAllStalledSpeakersAtOnceMixedWithFreshOnes` re-spaced its
fixture so charlie's 1.5 s frame age stays under threshold while
alice/bob's 4 s ages exceed it. 12/12 pass.
2026-05-05 22:47:07 +00:00
Claude 6e4df4aad4 fix(nests): cliff-detector — reset lastFrameAt on recycle + bump cooldown to 30s
Production logs at commit ea08c43 (run 18:05:14) showed the cliff
detector now correctly fires (`active=1 announced=1` after the
channelFlow chain fix) — but it then thrashes the relay:

  18:05:25.488  cliff #1 → recycle, session 1 had 4 groups (3 s of audio)
  18:05:40.512  cliff #2 → recycle, session 2 had 36 groups (7 s of audio)
  18:05:48.538  cliff #3 → recycle, session 3 had 0 groups, then ...
  18:05:56.566  cliff #4 → recycle, session 4 can't even subscribe
                ("subscribe stream FIN before reply" — relay refusing)

Two compounding issues:

1. `lastFrameAt[pubkey]` was NOT reset when the cliff detector
   triggered a recycle. The next 1-second tick after the 8 s
   cooldown re-evaluated `lastFrameAt[pubkey].elapsedNow()`, which
   was still the giant pre-recycle value (now > 12 s). The check
   `>= 4_000ms` immediately tripped, recycling AGAIN regardless of
   whether the new session had begun delivering frames. The
   detector treated the recycle moment as if no time had passed.

2. The 8 s cooldown was the bare minimum needed for one reconnect
   handshake. It did NOT include time for moq-rs to drain its
   per-subscriber forward task queue from the prior subscription.
   Aggressive recycles every ~12 s drove the relay into a state
   where it FIN'd new subscribe bidis without responding —
   visible as "NestsListener.subscribe requires Connected state,
   was Closed" loops on the receiver after the 4th cliff.

Two changes:

a) When the cliff detector fires, before calling `recycleSession()`
   it now writes `lastFrameAt[pubkey] = recycleMark` for every
   stalled pubkey. This treats the recycle as a synthetic frame
   event so the cliff timer restarts from the recycle moment.
   The new session has the full `ROOM_AUDIO_CLIFF_TIMEOUT_MS`
   (4 s) to deliver before the next cliff check trips, instead of
   tripping the moment the cooldown ends.

b) `ROOM_AUDIO_CLIFF_COOLDOWN_MS: 8_000L → 30_000L`. Gives moq-rs
   enough wall-clock between recycles to drain its per-subscriber
   forward queue before the next subscribe lands. Audible-gap
   cost rises from ~5 s to ~30 s in the worst-case fully-stalled
   relay, but this is the correct trade: 30 s of silence followed
   by recovered audio beats endless silence with the relay locked
   out.

Combined, the worst-case recycle cycle goes from ~12 s (8 s
cooldown + 4 s threshold = next recycle, relay degrades fast) to
~34 s (30 s cooldown + 4 s threshold = next recycle, relay can
recover). For the steady state where the relay is healthy and
the recycle DOES help, the new session's first frames clear the
threshold on `lastFrameAt` and no second recycle fires at all.

Tests: `CliffDetectorTest`'s 12 pure-predicate cases stay green
(updated `cooldownSuppressesRecycleEvenWhenStalled` and
`cooldownReleasesAfterTimeoutPasses` to use the new 30 s default).
The `lastFrameAt` reset on recycle is in the live detector loop,
not the predicate, so it's covered by integration runs rather
than the pure-function suite.
2026-05-05 22:13:12 +00:00
Claude ea08c43b71 fix(nests): don't teardown on wrapper Closed — let orchestrator reconnect
The cliff-detector recycle path emits Closed → Reconnecting → Connecting
→ Connected from the wrapper. The Closed branch was calling teardown(),
which cancels cliffDetectorJob, announcesJob, and the wrapper itself,
preventing the orchestrator from ever reopening the inner listener.

Result pre-fix (visible in receiver log): cliff-detector fires after 4s
of silence, teardown runs ~500ms later, wrapper never reopens, room
permanently dead.

User-initiated close goes through disconnect() / onCleared() which call
teardown directly; the wrapper's subsequent Closed emission is redundant
for those paths, so no-op'ing it is safe. UI still reflects Closed via
state.toUiState(ui.connection).

https://claude.ai/code/session_01UHN3fnXzWdj8UbSXnxSwwv
2026-05-05 21:27:35 +00:00
Claude 457e0f5997 fix(nests): wrapper.announces() ALSO needs channelFlow (collectLatest emits cross-coroutine)
The channelFlow conversion in f17e7ad fixed `MoqLiteNestsListener.announces`
but the SAME `IllegalStateException: Flow invariant is violated` kept
firing in the next two-phone repro (commit f17e7ad, run 15:46:51) — this
time the offending `flow{}` was one layer up, in
`ReconnectingNestsListener.announces`:

  override fun announces(): Flow<RoomAnnouncement> =
      flow {
          activeListener.collectLatest { listener ->
              ...
              runCatching {
                  listener.announces().collect { emit(it) }  // <-- HERE
              }
          }
      }

`Flow.collectLatest { lambda }` cancels and restarts a CHILD coroutine
for each upstream emission. The lambda body runs in that child, NOT in
the surrounding flow{} body's coroutine. So when the lambda invokes
`emit(it)`, it's emitting from a child coroutine. `flow{}`'s
SafeFlow guard rejects this with the same "Emission from another
coroutine is detected" error the inner listener was throwing before
my last fix.

Net effect on the user's repro: the inner channelFlow now correctly
sends RoomAnnouncement to the wrapper's `listener.announces().collect`
lambda, but that lambda's `emit(it)` to the wrapper's flow{} body
fails the same SafeFlow check, the wrapper's runCatching swallows the
exception (as `iter=2 inner collect ended IllegalStateException ...
fwd=1`), `_announcedSpeakers` stays empty, cliff detector never fires.

Fix: convert `ReconnectingNestsListener.announces` from `flow{} + emit`
to `channelFlow{} + send`, matching the inner listener's shape. The
combination of `channelFlow + collectLatest + send` is the canonical
pattern in kotlinx-coroutines for "switch-map" semantics with cross-
coroutine production. `awaitClose { }` is empty because `collectLatest`
on an infinite-StateFlow never completes naturally — cancellation
propagates through structured concurrency when the consumer cancels
the channelFlow.

Tests: every nestsClient + commons unit test still passes, including
the 12 CliffDetectorTest cases pinning the predicate's behaviour.

Together with f17e7ad (inner channelFlow), this should finally close
the chain: inner emits RoomAnnouncement on its channelFlow → wrapper's
collectLatest receives it inside its child coroutine → wrapper's
channelFlow.send forwards across the channel → consumer's
observeAnnounces collect receives → `_announcedSpeakers` populates →
cliff detector tick reports `announced=1` → on a real cliff event the
recycle fires.
2026-05-05 19:50:23 +00:00
Claude f17e7adfa7 fix(nests): announces flow MUST use channelFlow, not flow{}
The diagnostic logging in 1fc8dbc surfaced the real root cause —
not the SharedFlow replay race I fixed in d6517cf, but a strict
flow{}-builder concurrency-confinement violation.

The receiver-side log (15:34:40, repro after the diagnostic patch)
showed:

  session.announce(prefix='') bidi pump emit #1 status=Active suffix='fe52579aa30e' (chunks=1)
  MoqLiteNestsListener.announces bidi#2 #1 status=Active suffix='fe52579aa30e'
  MoqLiteNestsListener.announces bidi#2 finally (emissions=1)
  wrapper.announces() iter=2 inner collect ended IllegalStateException:
    Flow invariant is violated:
    Emission from another coroutine is detected.
    Child of StandaloneCoroutine{Active}@e9b5981, expected child of
    StandaloneCoroutine{Active}@8309a26.
    FlowCollector is not thread-safe and concurrent emissions are
    prohibited.
    To mitigate this restriction please use 'channelFlow' builder
    instead of 'flow'

The session's bidi pump (`scope.launch { bidi.incoming().collect …
updates.emit(decoded) }`) emits to the announce SharedFlow from the
PUMP's coroutine. SharedFlow's `collect { … }` resumes the lambda
inline on the emitter's coroutine when there's no dispatcher hop,
so `MoqLiteNestsListener.announces`' `handle.updates.collect {
emit(RoomAnnouncement(…)) }` lambda fires on the pump's coroutine,
not the flow{} body's coroutine. `flow {}`'s SafeFlow guard throws
on the cross-coroutine emit. The wrapper's
`runCatching { listener.announces().collect { emit(it) } }` swallows
the exception, the wrapper's collect ends with `fwd=1`, and
`_announcedSpeakers` stays empty for the lifetime of the session.

That, in turn, kept the cliff detector permanently gated:

  cliff-detector tick=N active=1 announced=0 lastFrameAges=[fe52579a=…ms]

even though the receiver was clearly subscribed and clearly seeing
frames. The cliff detector requires the speaker to be in
`announcedSpeakers` to recycle, so the relay-forward cliff at ~135
streams went undetected — the original two-phone receiver-side
silence symptom.

Fix: switch `MoqLiteNestsListener.announces()` from `flow { … }` to
`channelFlow { … }`. `channelFlow` is the documented escape hatch
for cross-coroutine emission (the error message itself recommends
it). The new shape:

  channelFlow {
      val handle = session.announce(prefix = "")
      val pump = launch {
          try {
              handle.updates.collect { send(RoomAnnouncement(…)) }
          } finally {
              withContext(NonCancellable) {
                  runCatching { handle.close() }
              }
          }
      }
      awaitClose { pump.cancel() }
  }

Notes:
  - `send` (channel) replaces `emit` (flow) — channelFlow buffers
    via a Channel, so cross-coroutine producers are explicitly
    supported.
  - The inner `collect` is wrapped in a launched child of the
    channelFlow scope so we can use `awaitClose` for the producer-
    side teardown contract channelFlow requires.
  - `handle.close` is suspend (FINs the bidi + joins the pump);
    putting it in the launched pump's `finally` under
    `NonCancellable` keeps the FIN reliable when the consumer
    cancels mid-stream.

Diagnostic logging from 1fc8dbc preserved — the next two-phone
repro should now show:

  observeAnnounces #1 active=true pubkey='fe52579aa30e' → ADD
  cliff-detector tick=N active=1 announced=1 lastFrameAges=[…]

…and on a real cliff event, the recycle should fire.

Tests: full suite still passes
  - CliffDetectorTest: 12/12
  - NestPlayerTest: 12/12
  - NestBroadcasterTest: 6/6
  - MoqLiteSessionTest: 11/11
  - All other nestsClient jvmTest classes (~240 tests across 37
    classes) green.
2026-05-05 19:39:23 +00:00
Claude 1fc8dbceee debug(nests): trace announce-flow chain to localise still-empty _announcedSpeakers
The replay=64 SharedFlow fix in d6517cf did NOT close the receiver's
"announced=0" symptom — the next two-phone repro (15:20:08 onward)
still shows the cliff detector ticking with `active=1 announced=0`
indefinitely, even after the session-internal pumpAnnounceWatch
clearly received an Active update. Either moq-rs sends Active to
only the FIRST announce bidi (so the VM-level second bidi never
gets it), OR the chain from that bidi to `_announcedSpeakers` is
broken at a layer my last fix didn't visit.

Adds focused logging at every step of the announce chain so the
next repro pins down which one. All NestRx-tagged:

`MoqLiteSession.announce` (per-bidi pump):
  - "session.announce(prefix='…') bidi opened, pump launching"
  - "session.announce(prefix='…') bidi pump emit #N status=… suffix='…' chunks=…"
  - "session.announce(prefix='…') bidi.incoming() ended naturally chunks=… emits=…"
  - per-emission so we can compare bidi#1 (session-internal) vs
    bidi#2 (VM-level) emit counts. If bidi#2 has 0 emits, the
    relay is sending Actives to only one bidi.

`MoqLiteNestsListener.announces`:
  - "MoqLiteNestsListener.announces flow STARTING (state=…)"
  - "MoqLiteNestsListener.announces opened bidi#2 (VM-level)"
  - per-emission "bidi#2 #N status=… suffix='…'"
  - "bidi#2 collect ENDED naturally" / "finally emissions=N"

`ReconnectingNestsListener.announces` (wrapper):
  - "wrapper.announces() flow starting collect on activeListener"
  - "wrapper.announces() iter=N activeListener=set/null"
  - "wrapper.announces() iter=N inner state=Connected/Closed/…"
  - "wrapper.announces() iter=N inner collect ended naturally/X fwd=N"

`NestViewModel.observeAnnounces`:
  - "observeAnnounces launching collect on l.announces()"
  - per-emission "observeAnnounces #N active=… pubkey='…' → ADD/REMOVE"
  - "observeAnnounces collect ENDED naturally/X (emissions=N,
    _announcedSpeakers.size=…)"

After the next repro, the receiver-side log will show one of:
- bidi#2 never opens (terminalOrConnected != Connected somehow)
- bidi#2 opens, no chunks/emits → relay-side issue
- bidi#2 emits, but `MoqLiteNestsListener.announces` doesn't
  forward → bug in the flow body
- forwarding works but observeAnnounces collect ends → bug in
  `ReconnectingNestsListener.announces` collectLatest behavior
- everything works but `_announcedSpeakers.size=0` → mutation lost

Tests: full suite still passes (CliffDetectorTest 12, NestPlayerTest
12, NestBroadcasterTest 6, MoqLiteSessionTest 11, …).
2026-05-05 19:27:19 +00:00
Claude d6517cf346 fix(nests): announce SharedFlow late-attach race dropped Active updates
The cliff-detector diagnostic logs from the user's two-phone repro
(commit f2205dc, run 15:06) showed the smoking gun — every tick from
the receiver-side cliff detector said `announced=0` even though an
Active announce had clearly been received earlier (the session-level
`pumpAnnounceWatch` logged it). The cliff detector only acts on
speakers that are in `_announcedSpeakers`, so it never tripped, even
when frames clearly stopped flowing for 6+ s.

Root cause: `MoqLiteSession.announce` builds a `MutableSharedFlow`
with `replay = 0` to bridge the bidi pump and the consumer's
`collect`. With Kotlin's `MutableSharedFlow(replay = 0)`, an `emit`
that happens BEFORE the first collector subscribes is silently
dropped — there's no buffer for items emitted into a flow with no
subscribers. The bidi pump is launched inside `announce()` and
starts collecting `bidi.incoming()` immediately; the caller (the
flow returned by `MoqLiteNestsListener.announces()`) attaches its
collector AFTER `session.announce()` returns. In between, if the
relay sends an Active update (which it does the moment the
broadcaster's session is registered), the pump's emit hits a flow
with no collectors and is lost.

In the user's logs both an internal session-level
`pumpAnnounceWatch` AND the VM-level `observeAnnounces` open
separate announce bidis. The internal one started first and won
the race for its bidi's Active. The VM-level one lost — the bidi
pump emitted Active before the consumer subscribed, the SharedFlow
dropped it, and `_announcedSpeakers` never populated.

Fix: change the announce SharedFlow to `replay = 64` with
`BufferOverflow.DROP_OLDEST`. Late-attaching collectors now see up
to the last 64 announces (far more than nests rooms have
speakers — typical stage size is ≤ 10). The bidi pump's emit
still never suspends, so QUIC-level backpressure can't propagate.
The replay cache evicts the oldest item if more than 64 announces
arrive before any collector attaches; for the production workload
this is unreachable.

Tests: nestsClient jvmTest passes 57/57 unchanged
(MoqLiteCodecTest, MoqLiteFrameBufferTest, MoqLitePathTest,
MoqLiteSessionTest, NestBroadcasterTest, NestPlayerTest). The
session-level pumpAnnounceWatch path used the same SharedFlow but
its caller subscribes from `scope.launch { pumpAnnounceWatch(...) }`
inside `ensureAnnounceWatchStarted` — close enough in time that it
won the race in production. The fix closes the race for both
callers regardless of timing.
2026-05-05 19:11:51 +00:00
Vitor Pamplona 46a07cfe43 Merge pull request #2739 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-05 15:10:04 -04:00
Vitor Pamplona 84bd3560e5 Merge pull request #2736 from vitorpamplona/claude/disable-vlc-ci-checks-4BD0q
Cache vlc-setup downloads in desktop CI
2026-05-05 15:09:55 -04:00
Claude f2205dc97e test(nests): pure unit tests for cliff-detector predicate + diagnostics + Connected restart
The two-phone repro on commit cb61082 showed the cliff still triggers
at ~13 s with framesPerGroup=10 (last drainOneGroup at ts 14:26:14,
broadcaster keeps pushing through 14:26:25+ unimpeded), but the cliff
detector did NOT fire — no `cliff-detector: announced+subscribed but
silent…` log in the receiver's NestRx output despite the gap clearly
exceeding the 4 s threshold. Three changes here narrow the gap.

1. Extract `computeStalledSpeakers` as a pure top-level internal
   function over `Map<String, TimeMark>`. The detector loop now calls
   it; the rest of the predicate logic (cooldown, announced ∩ active
   ∩ stale, ≥ inclusive boundary) is now testable without standing up
   the NestViewModel coroutine machinery.

2. New `CliffDetectorTest` (commonTest, 12 tests) drives
   `computeStalledSpeakers` with a `kotlin.time.TestTimeSource`.
   Pins the boundary cases the next refactor would otherwise drift
   on: at-threshold inclusive, just-under empty, no-frame-yet ignored,
   not-announced ignored, multi-speaker classification, cooldown
   suppress, cooldown release, and a few more. All 12 pass.

3. Add diagnostic logging to the live cliff detector. The two-phone
   logs proved the predicate's logic works (the `lastFrameAt` mark
   was clearly aged past the threshold), so the silence has to be
   in the gating: `listener` null, connection state not Connected,
   or `_announcedSpeakers` not containing the pubkey at scan time.
   New logs:
     - "cliff-detector launched" once on start
     - "cliff-detector tick=N gated: listener=… conn=…" every 5 s
       when gated
     - "cliff-detector tick=N active=… announced=… lastFrameAges=…"
       every 5 s when running
     - "cliff-detector EXITED after N ticks (closed=…)" on cancel
   so the next repro tells us which gate is failing.

4. Defensive restart on `NestsListenerState.Connected`. The Closed
   branch in `observeListenerState` calls `teardown(targetState =
   Closed)`, which cancels `cliffDetectorJob` along with the rest of
   the per-session jobs. If the cliff detector itself is what
   triggered the recycle, the wrapper emits Closed → Reconnecting
   → Connected; we'd come back online with a dead detector and the
   next cliff event would slip past undetected. Calling
   `startCliffDetector()` (idempotent) on every Connected entry
   keeps the detector alive across the cycle.

Defaults stay where they were: 4 s `ROOM_AUDIO_CLIFF_TIMEOUT_MS`,
1 s scan, 8 s cooldown — the unit tests also pin those default
constants by relying on the function's defaults in two of the cases.
2026-05-05 18:54:25 +00:00
Crowdin Bot 0887c65ce9 New Crowdin translations by GitHub Action 2026-05-05 18:18:54 +00:00
Vitor Pamplona 3915d98774 Merge pull request #2738 from vitorpamplona/claude/fix-session-swap-timeout-VB9NT
Fix race condition in ReconnectingNestsListenerTest frame delivery
2026-05-05 14:17:08 -04:00
Claude bd681a0a5f test(nests): close FRAME1 delivery race in subscribeSpeaker_survives_session_swap
The test emitted FRAME1 into first.frames and immediately called
first.fail(...), trusting that FRAME1 would propagate through the pump
to the wrapper's frames before the session swap. emit() is
non-suspending — it only enqueues FRAME1 into the pump's slot. On
slower hosts (observed on macOS CI) the orchestrator's reconnect path
can flip activeListener to the second session before the pump's
collect lambda runs, at which point collectLatest cancels
pump-iteration-1 mid-resume and FRAME1 is dropped.  The consumer's
take(2) then only ever sees FRAME2 and the async's withTimeout fires
after 5 s.

Add a consumerProgress StateFlow that the consumer's collector bumps
on each frame, and wait for it to reach 1 before failing the listener.
Same shape as the existing consumerSubscribed gate that closed the
"emit before consumer subscribes" race.
2026-05-05 18:06:56 +00:00
Claude cb61082bc4 fix(nests): close receiver-audio dropout cluster + auto-start broadcast on host create
Six changes that together close out the bug class the user reported
(receiver "blinks green ring for ~5 s then stops") and the related
host-side "circular spinner until first unmute" UX issue. Each is
defensible independently; together they form a defense-in-depth
against the moq-rs production-relay forward-queue cliff documented
in `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.

#3 — `NestMoqLiteBroadcaster.onLevel` only fires when
`current.send(opus)` returned `true` (frame reached an inbound
subscriber). Two-phone logs showed the host's green/glow firing for
the first ~7 s while no listener was attached and after a relay-side
cliff while no audio was on the wire. The local "I'm broadcasting"
ring now tracks the wire, not the runCatching outcome.

#5 — `DEFAULT_FRAMES_PER_GROUP: 5 → 10` (200 ms of audio per
group, 5 streams/sec). Two-phone production logs showed the relay
still cliffing at ~135 streams under sustained load with the old
5-frame default — same bug class the plan thought it had closed,
just shifted by half. Doubling the pack roughly doubles the
worst-case cliff window. Kept the existing `framesPerGroup = 5`
test scenarios intact since they're explicit on the call site.

#4 — `SUBSCRIBE_RETRY_BACKOFF_INITIAL_MS: 250 → 100`. Logs of the
join path showed 5 retries (250+500+1000+1000+1000 = 3.75 s) of
"subscribe stream FIN before reply" before the relay observed the
broadcast's announce. New ladder is 100+200+400+800+1000 = 2.5 s.
The MAX cap stays at 1000 ms so a permanently-gone publisher still
doesn't hammer the relay.

#2 — `NestViewModel.openSubscription` now passes
`maxLatencyMs = 500L` on every speaker SUBSCRIBE (new constant
`ROOM_AUDIO_MAX_LATENCY_MS`). Tells moq-rs to evict groups older
than 500 ms from the per-subscriber forward queue rather than
queuing them up to the relay's `MAX_GROUP_AGE = 30 s` default —
caps the queue growth that triggers the cliff. The wire support
existed in the listener; only the call site was passing 0L.

#1 — listener-side cliff detector. New `cliffDetectorJob` in
`NestViewModel` periodically scans `activeSubscriptions` against
`lastFrameAt` (per-pubkey monotonic TimeMark, updated in
`onSpeakerActivity`). When a speaker has been announced Active AND
we have an active subscription AND no frames have arrived for
`ROOM_AUDIO_CLIFF_TIMEOUT_MS = 4 s`, force a `recycleSession()` so
the orchestrator opens a fresh QUIC transport — a clean reset of
the relay's per-subscriber forward queue. `ROOM_AUDIO_CLIFF_COOLDOWN_MS = 8 s`
prevents back-to-back recycles while the new session is mid-handshake.
Started from `launchConnect` after `observeAnnounces`, cancelled in
`teardown`. Per-pubkey `lastFrameAt` entry dropped on
`closeSubscription` so a removed speaker doesn't trigger a stale
recycle.

#6 — auto-start broadcast pre-muted on host create.
`NestViewModel.startBroadcast` gets an `initialMuted: Boolean = false`
parameter; when `true` the broadcaster opens the publisher session,
calls `setMuted(true)` before the UI flips to `Broadcasting`, and the
mic stays open + capture loop keeps running with `if (muted) continue`
gating sends. New `LaunchedEffect(speakerPubkeyHex)` in
`OnStageIdleControls` calls `startBroadcast(initialMuted = true)`
automatically when mic permission is already granted, so a host who
just created a room sees their avatar transition to "Live (silent)"
and listeners can subscribe immediately without waiting for the host
to tap "Talk" first. Tap-to-talk path also passes `initialMuted = true`
now — unmute is a separate, explicit step on the mic toggle that
appears once we're in `Broadcasting(isMuted = true)`. Permission-
denied case still requires the manual Talk-button tap to launch the
runtime permission prompt.

Diagnostic logs from the previous two debug commits remain in place
(throttled to ≤ 1 line/sec/speaker at 50 fps capture cadence) so the
next two-phone repro can validate the fixes against logcat directly.
2026-05-05 16:44:39 +00:00
Claude 0c8e1ef58e chore(desktop): drop skipVlcSetup escape hatch
The -PskipVlcSetup / AMETHYST_SKIP_VLC opt-out is no longer used: CI
relies on the actions/cache step in build.yml to avoid hitting
get.videolan.org, and the in-build retry budget covers cache misses.
Remove the dead opt-out from desktopApp/build.gradle.kts and the env
export from the session-start hook.
2026-05-05 16:35:28 +00:00
Vitor Pamplona c0262abe59 Merge pull request #2735 from vitorpamplona/claude/fix-keyboard-padding-messages-nIBGm
Add IME padding to MessagesTwoPane Scaffold
2026-05-05 12:33:59 -04:00
Claude 697a749243 ci(desktop): cache vlc-setup downloads, drop skipVlcSetup bypass
Replace the -PskipVlcSetup=true CI bypass with a per-OS GitHub Actions
cache of ~/.gradle/vlcSetup. Cache key is hashFiles('desktopApp/build.gradle.kts')
so a VLC version bump invalidates cleanly; restore-keys lets unrelated
edits to that file still warm-start.

Cache hit: vlcDownload / upxDownload are up-to-date and we never touch
get.videolan.org. Cache miss (first run after version bump, or a new
runner): we fall back to the network, where the existing 5-attempt
retry budget in build.gradle.kts already handles flakes.

The skipVlcSetup property + AMETHYST_SKIP_VLC env hooks stay in
desktopApp/build.gradle.kts as a local-dev / CCW escape hatch — the
CCW egress can't reach get.videolan.org at all and has no actions/cache.
2026-05-05 16:28:41 +00:00
Claude f922cfd5bc fix(desktop): also disable vlc/upx Extract tasks under skipVlcSetup
Disabling only vlcDownload/upxDownload/vlcSetup left the chained
*Extract tasks in the graph with @InputFile properties pointing at
archives that were never downloaded — Gradle 9 then fails the build
during input validation:

  property 'upxArchiveFile' specifies file
  '/root/.gradle/vlcSetup/upx-4.2.4.tar.xz' which doesn't exist.

Disable the full vlc-setup plugin task set
(vlcDownload, vlcExtract, vlcFilterPlugins, vlcCompressPlugins,
upxDownload, upxExtract, vlcSetup) so packaging skips VLC bundling
cleanly.

Verified locally: :desktopApp:packageDeb now proceeds past those tasks
straight to jpackage with -PskipVlcSetup=true.
2026-05-05 16:11:55 +00:00
Vitor Pamplona 3364890a01 Merge pull request #2737 from vitorpamplona/claude/fix-group-message-notifications-UNKZq
Add notifications for Marmot group messages (kind:445)
2026-05-05 12:10:54 -04:00
Claude 30d8d1bb6f refactor(notifications): type notifyGroupMessage as ChatEvent
The previous signature took Event and runtime-checked kind == 9 inside
the notifier. That left the contract implicit: any caller could pass a
reaction or control message and the call would silently no-op.

Type the parameter as ChatEvent so the kind:9 restriction is structural,
and move the narrowing (`is ChatEvent`) to the GroupEventHandler call
site where the inner event is parsed. Reactions, deletions, and other
inner kinds now can't reach the notifier in the first place.

Drops the runtime kind check and the now-stale comment about reactions.
2026-05-05 15:57:36 +00:00
Claude be8dd0a3bc debug(nests): add playback-path logs to localise receiver silence
The wire is healthy: drainOneGroup logs show every group reaching the
receiver with 5 frames each, no drops, no parse errors, no pump exits,
no announce-ended teardown. Yet the green ring on the receiver still
blinks-and-stops. The bug must be in the playback pipeline below
drainOneGroup. Add NestPlay-tagged logs there so the next repro
identifies whether the failure is decoder, AudioTrack allocation,
beginPlayback, or write-blocking.

NestPlayer.play (`NestPlay` tag):
  - log start with prerollFrames
  - log preroll flush + beginPlayback transition
  - throttled per-N counters: receivedObjects / decodedFrames /
    emptyDecodes / enqueued
  - log decoder.decode throws explicitly
  - log empty-pcm decoder returns (separate from throws)
  - log enqueue duration when > 50 ms (catches AudioTrack.write-blocking)
  - log flow COMPLETED / cancelled / pipeline threw with final counts

AudioTrackPlayer (`NestPlay` tag):
  - log start() + the constructed AudioTrack's state / playState /
    bufferSizeBytes / minBuffer
  - log beginPlayback's t.play() return + post-call state
  - log AudioTrack.write partial / error returns
  - log setMuted / setVolume changes (silent mute is a known
    suspect for "audio not playing")

MediaCodecOpusDecoder (`NestPlay` tag):
  - log codec name on successful allocation
  - log allocation failure with exception type / message

All log calls use the lambda overload so format work only runs when
the tag is enabled. Throttled counters keep logcat at < 1 line/sec
per speaker even at 50 fps capture cadence.
2026-05-05 15:56:47 +00:00
Claude ab68f827ce ci(desktop): add skipVlcSetup opt-out to bypass flaky VLC downloads
get.videolan.org regularly times out from GitHub-hosted runners and
Claude Code web sandboxes, taking the desktop CI build down with it
even though the failure is unrelated to the change under review.

Wire a -PskipVlcSetup=true (env: AMETHYST_SKIP_VLC=true) opt-out in
desktopApp/build.gradle.kts that disables the vlcDownload, upxDownload,
and vlcSetup tasks. Pass it from build.yml so PR CI no longer gates on
VLC reachability, and export the env from the CCW session-start hook.

Release packaging (create-release.yml) intentionally does not set the
flag, so shipped artifacts still bundle VLC.
2026-05-05 15:41:01 +00:00
Claude cfde4a5bf7 fix(notifications): popup for Marmot group messages (kind:445)
Welcomes (kind:444) already had a direct-dispatch path because they have
no `p` tag and the cache-observer route can't match them to an account.
Group messages (kind:445) have the same problem — recipients are routed
by the `h` tag carrying the nostr_group_id — but were silently missed,
so the user only saw a popup when first added to a group, never for any
chat that followed.

Mirror the notifyWelcome path: after MarmotInboundProcessor decrypts
and verifies an ApplicationMessage and we've persisted the inner event
for the first time, fire notifyGroupMessage on NotificationDispatcher.
The notifier filters to ChatEvent (kind:9) so reactions, deletions and
control messages stay silent (matching how NIP-17 only notifies on
kind:14), applies the same 15-min freshness and self-author gates as
the other DM paths, and uses the marmot:<groupHex>?account=<npub>
deep-link scheme so taps land in the right chatroom.

Only fires on first-time decryption (isNew) so a relay re-broadcast or
on-disk persist replay can't double-notify.
2026-05-05 15:36:35 +00:00
Claude 3a2010d9c0 debug(nests): add diagnostic logs to localise receiver audio dropout
Adds focused logs at every suspicious point on the moq-lite receive +
publish paths so we can pin down why the receiver phone shows a brief
~5s window of activity then nothing while the speaker phone's UI looks
healthy.

Receiver-side (`NestRx` tag):
  - SUBSCRIBE send / SUBSCRIBE_OK / SUBSCRIBE bidi exit (with reason)
  - announce-watch update per Active/Ended event, plus which subs get
    closed when an Ended hits
  - pumpUniStreams start + naturally-ended / threw with stream count
  - drainOneGroup header, FIN (frames + droppedNoSub + trySend fail
    counters), and per-throw with stream sequence
  - wrapper handle attached / objects flow ENDED with emitted count

Broadcaster-side (`NestTx` tag):
  - inbound ANNOUNCE / SUBSCRIBE accepted / track-mismatch ignored
  - registerInboundSubscription / removeInboundSubscription with
    inboundSubs.size
  - throttled "send returning false (no subs / publisher closed)" so
    the speaker UI's "I'm broadcasting" claim can be cross-checked
    against frames actually leaving the wire
  - send threw (consecutive count) so a sustained openUniStream
    failure surfaces before MAX_CONSECUTIVE_SEND_ERRORS bails
  - openGroupStream open + per-throw

All log calls use the lambda overload so the throttled/string-format
work only runs when the tag is enabled.
2026-05-05 15:36:17 +00:00
Claude d7c3c22522 fix(messages): apply imePadding to two-pane Scaffold so edit field clears keyboard
The single-pane chat path wraps content in DisappearingScaffold, which applies
Modifier.imePadding() at the root surface. The dual-pane MessagesTwoPane uses a
plain Scaffold with no IME inset handling, so when the keyboard opens the
PrivateMessageEditFieldRow stayed hidden behind it.

https://claude.ai/code/session_012sPZ9KbbkTzdPL2KTn29ak
2026-05-05 15:23:01 +00:00
Vitor Pamplona f2c8e154cf Merge pull request #2734 from vitorpamplona/claude/fix-moq-protocol-exception-q1gTL
Fix header protection sampling for short QUIC payloads
2026-05-05 10:56:49 -04:00
Claude 410123e281 fix(nests): reset CreateNest sheet state after successful publish
CreateNestViewModel is keyed by user pubkey via `viewModel(key = …)`,
so the same instance survives across sheet open/close cycles. After a
successful publish the form was left untouched: room name, summary,
image URL stayed populated and — most visibly — `isPublishing` was
never cleared, so on the second open the Submit button rendered as a
spinning progress indicator forever.

Reset the form to fresh defaults (re-seeded from the user's saved
kind-10112 list) at the end of `publishAndBuildLaunchInfo()`, right
before returning the launch info. The sheet is about to dismiss
anyway, so the user never sees the in-place reset; the next open
behaves like a first open.
2026-05-05 14:17:05 +00:00
Claude edd6eb5c10 fix(quic): pad short plaintext payloads for HP sample (RFC 9001 §5.4.2)
ShortHeaderPacket.build / LongHeaderPacket.build crashed with
`IllegalArgumentException: packet too short for HP sample` whenever the
plaintext payload was small enough that pnLen + payload < 4 — most
visibly on the 1-RTT path when buildApplicationPacket emitted a single
1-byte PING (PTO probe with no ACKs queued and no streams to drain) and
the packet number still fit in 1 byte. The crash tore down the writer
loop, which surfaced upstream as moq-lite "subscribe stream FIN before
reply" because in-flight bidi streams got FIN'd by the peer.

RFC 9001 §5.4.2 mandates the sender pad the plaintext so the encrypted
output (plaintext + 16-byte AEAD tag) has at least 20 bytes after the
packet-number offset for the 16-byte HP sample. The fix pads the
plaintext with trailing 0x00 bytes — those decode as PADDING frames per
RFC 9000 §19.1 and decodeFrames already absorbs them. For long-header
packets the padded size feeds back into the Length varint.
2026-05-05 13:37:41 +00:00
Vitor Pamplona 8f2bbabb32 Merge pull request #2733 from vitorpamplona/claude/fix-nests-audio-dropout-EGlUZ
Eliminate audio gaps during JWT refresh and improve network resilience
2026-05-05 09:16:49 -04:00
Claude 003cf42564 fix(nests): self-audit pass — two real bugs + robustness + tests
Audit of the four prior commits found two genuine regressions and two
robustness gaps in the new code paths.

Bug A: NestForegroundService.networkCallback dropped the publish for
the most important scenario it was added to handle. The earlier guard
`if (previous != null && previous != network)` suppressed both the
registration-time first onAvailable AND the legitimate WiFi-loss-
then-cellular-available path. On a WiFi → cellular handover the
sequence is `onLost(wifi)` (clears currentDefaultNetwork to null)
followed by `onAvailable(cellular)` — which then looks identical to
the registration callback to the guard, so no publish fires and the
QUIC session sits on the dead socket until PTO. Replaced the implicit
"previous == null" suppression with an explicit `seenInitialNetwork`
flag that's set true on the first onAvailable and never cleared, so
post-onLost reconnects publish correctly.

Bug B: requestAudioFocus result handling was too permissive. The
shape `if (result == AUDIOFOCUS_REQUEST_FAILED) TransientLoss else
Granted` falls through to Granted on the runCatching exception path
(`result == null`) and on AUDIOFOCUS_REQUEST_DELAYED (= 2) — meaning
audio plays over an active call when the OS hasn't actually released
focus. Switched to a strict `if (result == AUDIOFOCUS_REQUEST_GRANTED)`
check; everything else (FAILED, DELAYED, exception) starts the VM
muted.

Robustness: NestViewModel.openSubscription's onError callback used to
swallow every AudioException, which fit the per-packet decoder-error
case but turned PlaybackFailed and DeviceUnavailable from a deferred
beginPlayback into a permanent "Connecting…" spinner on the speaker
tile. Now we discriminate by AudioException.Kind: decoder/encoder
errors stay swallowed (Opus PLC papers them over), but PlaybackFailed
and DeviceUnavailable roll the slot back so a future reconcile can
retry.

Pre-roll ordering swap + tests: NestPlayer.play used to call
beginPlayback BEFORE flushing the pre-roll buffer, leaving a
microsecond window where the AudioTrack hardware was playing against
an empty buffer. AudioTrack MODE_STREAM explicitly supports write()
before play(), so flush-then-beginPlayback is the textbook pattern
and what the fix now does. Three regression tests cover:
  - pre-roll defers beginPlayback until threshold is met (and the
    flush-then-begin ordering is observable)
  - partial pre-roll flushes when the upstream flow ends early
  - empty flow doesn't begin playback at all
The FakeAudioPlayer grows beginPlaybackCount + queuedAtBeginPlayback
fields so the tests can assert ordering directly.
2026-05-05 13:04:18 +00:00
Claude 6237c02c6f fix(nests): platform-side audio robustness — focus, AEC, route obs, network handover
Four follow-up fixes from the post-audit review (#4 / #5 / #6 / #7).

#6 AcousticEchoCanceler / NoiseSuppressor / AGC on the AudioRecord
   session. The VOICE_COMMUNICATION input source engages the platform
   echo canceller automatically on most modern Android devices, but a
   small set of older / OEM-customised devices only attach AEC under
   MODE_IN_COMMUNICATION — which an audio-room app deliberately
   avoids. Attaching the standalone audiofx effects to the
   AudioRecord's session id covers those devices without rerouting
   through the call audio path. All three are best-effort and a no-op
   on devices where the source already engages them.

#4 Real audio focus handling. The previous OnAudioFocusChangeListener
   was a no-op based on the assumption that the OS would auto-duck
   us; it doesn't (CONTENT_TYPE_SPEECH streams aren't auto-ducked).
   Inbound phone calls were mixing on top of room audio.
   - New `NestAudioFocusBus` (commons) — process-wide enum signal,
     decoupled from android.media so commons stays platform-free.
   - NestForegroundService translates AUDIOFOCUS_GAIN / LOSS_TRANSIENT*
     / LOSS into the bus enum.
   - NestViewModel observes the bus and silences both the listener
     playback (effective listen-mute = user OR focus) and the
     broadcast mic (effective mic-mute = user OR focus). User-visible
     mute states stay the user's choice so a focus regain restores
     them automatically.
   - The pipeline keeps running while focus is lost (decoder, capture,
     network) so unmute is sample-accurate when the call ends.

#5 AudioDeviceCallback observability. Registers a callback in
   NestForegroundService that logs Bluetooth / wired / USB headset
   attach + detach with device type + name. Doesn't drive playback
   decisions — Android's auto-routing handles route swaps — but
   makes "audio cut out when I plugged in headphones" reports
   correlatable with a concrete event for the first time.

#7 Network-change → fast reconnect. Without this, a Wi-Fi → cellular
   handover left the QUIC connection sitting on a now-dead socket
   until its PTO fired (~30 s) before the wrapper noticed. Now:
   - New `NestNetworkChangeBus` (commons) — collapses bursts of
     onLost/onAvailable into a single recycle event.
   - NestsListener + NestsSpeaker grow `recycleSession()` (default
     no-op); the reconnecting wrappers override to close the inner
     session so their orchestrator opens a fresh one.
   - NestForegroundService registers a default-network callback;
     suppresses the first onAvailable (registration callback)
     and only publishes on actual default-network changes.
   - NestViewModel observes the bus and calls recycleSession on
     both wrappers. The SubscribeHandle re-issuance pump (listener)
     and the hot-swap publisher pump (speaker) cut existing
     subscriptions / broadcasts onto the new session as soon as
     it lands — same paths the JWT-refresh recycle uses.
   - Manifest gains ACCESS_NETWORK_STATE for
     registerDefaultNetworkCallback.
2026-05-05 12:42:47 +00:00
Vitor Pamplona 4377a50295 Merge pull request #2732 from vitorpamplona/claude/modernize-settings-ui-QMMGn
Refactor settings screen UI with card-based layout and improved styling
2026-05-05 08:32:48 -04:00
Claude e4e55d1df6 fix(nests): three more dropout sources from the post-audit review
1. Split AudioPlayer.start() into allocate + beginPlayback to restore
   the synchronous DeviceUnavailable error path that the previous
   pre-roll fix collapsed.
   - AudioPlayer gains `beginPlayback()` (default no-op) so test
     fakes / desktop sinks aren't forced to grow a method they
     don't need.
   - AudioTrackPlayer.start() now allocates the AudioTrack + audio-
     priority writer thread but does NOT call AudioTrack.play();
     beginPlayback() flips the device into the playing state.
     AudioTrack in MODE_STREAM explicitly supports write() before
     play() per the platform docs, which is exactly the contract
     pre-roll wants.
   - NestPlayer.play() now calls player.start() synchronously
     (caller catches DeviceUnavailable + rolls back the slot like
     before) and defers beginPlayback() until pre-roll fills.

2. MediaCodecOpusDecoder: drain output + retry input dequeue before
   dropping a frame. The prior 10 ms input-buffer dequeue followed
   by an unconditional `return ShortArray(0)` turned every transient
   stall (thermal throttling, GC pause, output not yet pulled) into
   a 20 ms audio gap. Now we drain whatever output is ready —
   freeing input slots — then retry input dequeue with a 5 ms
   timeout. Only after both misses do we drop the packet, and even
   then we return any output samples that the drain produced so
   the player doesn't underrun on the back of one tight cycle. The
   decoder's drain logic is extracted into a `drainAvailableOutput`
   helper so both the pressure-relief path and the post-queue main
   drain share it.

3. ReconnectingNestsListener: exponential backoff for the opener-
   throws retry path. Replaces the flat 1 000 ms `SUBSCRIBE_RETRY_BACKOFF_MS`
   with 250 → 500 → 1 000 ms, reset on first successful subscribe.
   The 250 ms floor is well under moq-rs's typical announce-
   propagation latency (< 200 ms), so a subscribe-before-announce
   miss usually recovers fast enough that the wrapper SharedFlow's
   ~1.3 s buffer hides the gap entirely.
2026-05-05 12:17:59 +00:00
Claude 076b301d84 fix(nests): close 4 audio-dropout sources across listener + speaker
1. Listener-side pre-roll + bigger playback buffer + audio-priority thread.
   - NestPlayer buffers 5 decoded frames (~100 ms) before starting
     the AudioPlayer, masking the first-frame underrun that fires
     whenever Compose / GC briefly stalls Main.
   - AudioTrackPlayer sizes the AudioTrack at max(minBuffer*16, 250 ms)
     instead of minBuffer*4 (~80 ms) and writes via a per-instance
     audio-priority single-thread executor (Process.THREAD_PRIORITY_AUDIO)
     instead of Dispatchers.IO, so WRITE_BLOCKING never contends with
     unrelated IO work.

2. MoqLiteSession.subscribe: hoist response typeCode out of the
   collect lambda. readVarint advances pos permanently while
   readSizePrefixed only rolls back its own length-varint, so a
   chunk-split between type and body would re-read the body's size
   prefix as the type code on the next chunk and misframe the
   response. Mirrors the same fix already in handleInboundBidi.

3. MoqLiteSession.subscribe: register the subscription in the map
   BEFORE writing the SUBSCRIBE bytes on the wire. The relay can
   open the first group's uni stream before our continuation
   re-enters [state] to register; if so, drainOneGroup looked the
   id up against an empty map and silently dropped the frame
   (~1 frame / 20 ms gap on first attach). Wrap the post-register
   writes so a transport-failure unwinds the orphan registration.

4. Hot-swap moq-lite publisher across JWT-refresh boundaries.
   - NestMoqLiteBroadcaster: publisher is now @Volatile + supports
     swapPublisher(); capture loop snapshots the reference per frame
     and resets framesInCurrentGroup on swap.
   - MoqLiteNestsSpeaker implements a new internal
     HotSwappablePublisherSource interface that exposes
     openPublisherForHotSwap(track) without spinning up a broadcaster.
   - ReissuingBroadcastHandle keeps a single long-lived broadcaster
     across session recycles when the speaker supports hot swap;
     legacy IETF / fake speakers fall back to close-then-restart.
   - connectReconnectingNestsSpeaker.orchestrator hoists the old-
     session close onto a sibling launch so the wrapper can swap
     the publisher into the broadcaster on the new session before
     the old session's WebTransport drops. Eliminates the previously-
     accepted 50–150 ms audible silence at every JWT refresh.
2026-05-05 03:06:40 +00:00
Claude cc35a9b29a feat(amethyst): modernize Settings screen UI
Group rows into Material 3 cards by section, add tonal icon
containers and trailing chevrons, upgrade section header
typography, and visually mark the danger zone with error colors.
2026-05-05 02:28:26 +00:00
Vitor Pamplona 1acd54eada Merge pull request #2731 from vitorpamplona/claude/fix-nest-audio-display-3chAG
debug(nests): wire NestRx/NestTx logs across listener and speaker paths
2026-05-04 22:07:56 -04:00