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
@@ -34,6 +34,7 @@ import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.NestsSpeaker
import com.vitorpamplona.nestsclient.NestsSpeakerState
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.AudioException
import com.vitorpamplona.nestsclient.audio.AudioPlayer
import com.vitorpamplona.nestsclient.audio.NestPlayer
import com.vitorpamplona.nestsclient.audio.OpusDecoder
@@ -1015,7 +1016,32 @@ class NestViewModel(
val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) }
roomPlayer.play(
instrumented,
onError = { /* swallow per-packet decoder errors */ },
onError = { err ->
// Per-packet decoder errors are noise — Opus PLC
// papers over a single bad frame. Swallow those.
// PlaybackFailed (or DeviceUnavailable from a
// deferred [AudioPlayer.beginPlayback], or the
// audio-priority dispatcher dying mid-stream) is
// terminal: the device is wedged for THIS
// subscription. Roll the slot back so a future
// reconcile can retry rather than leave a
// perma-spinning speaker tile.
when (err.kind) {
AudioException.Kind.DecoderError,
AudioException.Kind.EncoderError,
-> {
Unit
}
AudioException.Kind.PlaybackFailed,
AudioException.Kind.DeviceUnavailable,
-> {
if (activeSubscriptions[pubkey] === slot) {
activeSubscriptions.remove(pubkey)?.let { closeSubscription(it) }
}
}
}
},
onLevel = { onAudioLevel(pubkey, it) },
)
slot.attach(handle, roomPlayer, player)