fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening #98

Merged
bvandeusen merged 2 commits from dev into main 2026-06-12 21:25:25 -04:00
Owner

Two commits on top of the WiFi-lock release (v2026.06.12 @ 9550d8da), all Android UPnP/Sonos cast resilience.

d6290a3e — don't drop Sonos (or blast local audio) on a phone-side network gap

A transient reachability gap (NetworkStatus → ServerDown while WiFi stayed associated) starved the poll past DROP_THRESHOLD, dropped the route, and fell back to the local player — which started blasting the song out of the phone while the Sonos was still streaming it. Gate the drop on NetworkStatusController: only drop when the phone's network is Healthy (renderer genuinely unreachable). While Unstable/ServerDown/Offline, hold the route and keep polling; reconcile on recovery.

fd7d6dac — adopt a running session + anti-stickiness + faster recovery

  1. Adopt instead of clear+reload. Selecting a renderer that's already playing our queue now attaches in place (sync local cursor to its live position, no removeAllTracksFromQueue, no restart) — skip/seek drive its live queue. Falls through to reload when it isn't ours. (PlayerController.moveCursorTo aligns the cursor without auto-playing.)
  2. Discovery expiry + selection revert. upsertRoute only ever added, so a powered-off renderer lingered forever and could pin a stale selection. Stamp lastSeen; prune non-responders after the picker's active scan; revert selection to phone when the selected route leaves discovery (while not casting). Picker-open-scan-tied — no background flicker.
  3. Immediate reconcile on NetworkStatus → Healthy while a route is active.

Verify on device (on battery — USB masks the network-gap class)

  • Cast → unplug → lock: survives (WiFi lock, from prior release).
  • Cast → brief network blip: Sonos keeps playing, phone does not blare local audio, reconciles on recovery.
  • Phone dropped but Sonos still playing → re-select the group: attaches to the live session (no restart), skip works.
  • Power a speaker off → reopen picker: it disappears from the list; selection reverts to phone.

CI green on dev (run 994: ktlint / detekt / unit).

🤖 Generated with Claude Code

Two commits on top of the WiFi-lock release (v2026.06.12 @ 9550d8da), all Android UPnP/Sonos cast resilience. ## `d6290a3e` — don't drop Sonos (or blast local audio) on a phone-side network gap A transient reachability gap (`NetworkStatus → ServerDown` while WiFi stayed associated) starved the poll past `DROP_THRESHOLD`, dropped the route, and fell back to the local player — which **started blasting the song out of the phone** while the Sonos was still streaming it. Gate the drop on `NetworkStatusController`: only drop when the phone's network is `Healthy` (renderer genuinely unreachable). While Unstable/ServerDown/Offline, hold the route and keep polling; reconcile on recovery. ## `fd7d6dac` — adopt a running session + anti-stickiness + faster recovery 1. **Adopt instead of clear+reload.** Selecting a renderer that's already playing our queue now *attaches in place* (sync local cursor to its live position, no `removeAllTracksFromQueue`, no restart) — skip/seek drive its live queue. Falls through to reload when it isn't ours. (`PlayerController.moveCursorTo` aligns the cursor without auto-playing.) 2. **Discovery expiry + selection revert.** `upsertRoute` only ever added, so a powered-off renderer lingered forever and could pin a stale selection. Stamp `lastSeen`; prune non-responders after the picker's active scan; revert selection to phone when the selected route leaves discovery (while not casting). Picker-open-scan-tied — no background flicker. 3. **Immediate reconcile on `NetworkStatus → Healthy`** while a route is active. ## Verify on device (on battery — USB masks the network-gap class) - Cast → unplug → lock: survives (WiFi lock, from prior release). - Cast → brief network blip: Sonos keeps playing, phone does **not** blare local audio, reconciles on recovery. - Phone dropped but Sonos still playing → re-select the group: **attaches to the live session** (no restart), skip works. - Power a speaker off → reopen picker: it disappears from the list; selection reverts to phone. CI green on dev (run 994: ktlint / detekt / unit). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bvandeusen added 2 commits 2026-06-12 21:25:20 -04:00
Observed on device: casting to Sonos on battery + screen locked, a
transient ~67s reachability gap (NetworkStatus -> ServerDown while WiFi
itself stayed associated) starved the 1 Hz poll past DROP_THRESHOLD. The
poll loop then dropped the route and fell back to the local player, which
honored the play-intent -- so the phone suddenly started playing the song
out loud locally while the Sonos was still happily streaming it.

A poll failure during a phone-side network outage means "we can't see the
renderer right now," not "the renderer died": a UPnP renderer streams
autonomously and keeps playing, and the local player we'd fall back to
can't reach the server either. Dropping is strictly worse than waiting.

Gate the drop on NetworkStatusController: only drop when the phone's
network is Healthy (renderer genuinely unreachable on an otherwise-fine
link). While Unstable/ServerDown/Offline, hold the route, keep polling,
and clear the failure streak so recovery re-evaluates from scratch rather
than re-dropping on the first post-recovery hiccup. The poll reconciles to
the renderer's real (advanced) position once the network returns.

Complements the CastNetworkLock fix: the lock reduces how often these gaps
happen; this stops a gap that does happen from punishing the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three related improvements to UPnP/Sonos session handling, on top of the
WiFi-lock + drop-suppression fixes.

1. Adopt a running session instead of clear+reload (the headline).
   Selecting a renderer always did removeAllTracksFromQueue + full reload --
   a jarring restart if the speaker was already playing our queue (e.g. after
   the phone got disconnected but the autonomous Sonos kept going). selectUpnp
   now probes the renderer first; if it's mid-playback on the same track id at
   the same queue index, we ATTACH in place: sync the local cursor to its
   position, wire ActiveUpnpHolder, start polling -- no clear, no reload, and
   skip/seek immediately drive its live queue. Falls through to clear+reload
   when it isn't our queue. New PlayerController.moveCursorTo aligns the local
   cursor without auto-playing.

2. Discovery expiry + selection revert (anti-stickiness). upsertRoute only
   ever added, so a powered-off renderer lingered in the picker forever and
   could pin a stale selection. Stamp lastSeen per route; after the picker's
   active M-SEARCH scan, prune routes that didn't re-announce. A collector
   reverts the selection to the phone when the selected route leaves discovery
   while we're not actively casting -- so a later play never targets a ghost.
   Pruning is tied to picker-open scans only (no background timer -> no row
   flicker).

3. Reconcile immediately on network recovery. When NetworkStatus flips back to
   Healthy while a route is active, nudge an immediate poll instead of waiting
   up to POLL_INTERVAL_MS -- the held session re-confirms the renderer in one
   round-trip.

Verified (read-only): the tap-play-onto-dead-route fallback still fires when
the phone's network is Healthy (the poll-loop drop path is unchanged for that
case); the drop-suppression gate only holds during phone-side outages, where a
local fallback couldn't play either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bvandeusen merged commit 3e258507bb into main 2026-06-12 21:25:25 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#98