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.
This commit is contained in:
Claude
2026-05-05 13:04:18 +00:00
parent 6237c02c6f
commit 003cf42564
4 changed files with 222 additions and 16 deletions
@@ -126,11 +126,22 @@ class NestPlayer(
suspend fun beginAndFlushIfNeeded() {
if (playbackBegun) return
if (preroll.isEmpty()) return
player.beginPlayback()
playbackBegun = true
// Flush BEFORE [beginPlayback] so the device starts
// playing against an already-populated buffer rather
// than emitting silence for the microseconds it
// takes the flush loop to fill. AudioTrack
// MODE_STREAM explicitly supports write() before
// play() per the Android docs — that's the
// textbook pre-roll pattern. Order matters
// because [beginPlayback] is the moment the
// hardware starts pulling samples; getting
// [enqueue] in first means the very first sample
// pulled is from our pre-rolled audio, not silence.
while (preroll.isNotEmpty()) {
player.enqueue(preroll.removeFirst())
}
player.beginPlayback()
playbackBegun = true
}
try {
objects.collect { obj ->