fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation) #97

Merged
bvandeusen merged 1 commits from dev into main 2026-06-12 19:45:45 -04:00
Owner

Problem

Casting to a UPnP renderer (Sonos) with the screen locked on battery would, after ~12 seconds, drop the renderer and fall back to a silent phone — while the Sonos was streaming fine the whole time.

Confirmed from logcat: shortly after screen-off the phone logged Unable to resolve host "minstrel.fabledsword.com" (its own DNS dying, not the server), the queue-extend aborted (1/58 appended), the 1 Hz poll then tripped DROP_THRESHOLD, and the local fallback came up active=null (no network either → silence).

Root cause

While casting, the wrapped ExoPlayer is paused, which releases its automatic WAKE_MODE_NETWORK WiFi/wake locks. Nothing else held the radio awake, so a locked, on-battery phone power-saved the WiFi within seconds and dozed the CPU — starving both the liveness poll to the renderer and the extend calls to the server. (USB charging masks this: no Doze while charging, which is why it only bit on battery.)

Fix

New CastNetworkLock: a high-perf/low-latency WifiManager.WifiLock + partial PowerManager.WakeLock, acquired when a UPnP route goes active and released on every teardown path (all funnel through holder.set(null)onActiveChanged(null)). Idempotent + synchronized. WAKE_LOCK permission already declared; minSdk 26 handled via WIFI_MODE_FULL_LOW_LATENCY (API 29+) / FULL_HIGH_PERF fallback.

Testing

Needs a real device test on battery (USB masks the bug): install → cast to Sonos → unplug → lock screen → confirm playback survives past ~15s.

CI verified green on dev (run 986: ktlint / detekt / unit tests).

🤖 Generated with Claude Code

## Problem Casting to a UPnP renderer (Sonos) with the screen locked **on battery** would, after ~12 seconds, drop the renderer and fall back to a silent phone — while the Sonos was streaming fine the whole time. Confirmed from logcat: shortly after screen-off the phone logged `Unable to resolve host "minstrel.fabledsword.com"` (its *own* DNS dying, not the server), the queue-extend aborted (1/58 appended), the 1 Hz poll then tripped `DROP_THRESHOLD`, and the local fallback came up `active=null` (no network either → silence). ## Root cause While casting, the wrapped ExoPlayer is paused, which **releases its automatic `WAKE_MODE_NETWORK` WiFi/wake locks**. Nothing else held the radio awake, so a locked, on-battery phone power-saved the WiFi within seconds and dozed the CPU — starving both the liveness poll to the renderer and the extend calls to the server. (USB charging masks this: no Doze while charging, which is why it only bit on battery.) ## Fix New `CastNetworkLock`: a high-perf/low-latency `WifiManager.WifiLock` + partial `PowerManager.WakeLock`, acquired when a UPnP route goes active and released on every teardown path (all funnel through `holder.set(null)` → `onActiveChanged(null)`). Idempotent + synchronized. `WAKE_LOCK` permission already declared; minSdk 26 handled via `WIFI_MODE_FULL_LOW_LATENCY` (API 29+) / `FULL_HIGH_PERF` fallback. ## Testing Needs a real device test **on battery** (USB masks the bug): install → cast to Sonos → unplug → lock screen → confirm playback survives past ~15s. CI verified green on dev (run 986: ktlint / detekt / unit tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bvandeusen added 1 commit 2026-06-12 19:45:38 -04:00
While casting, the wrapped ExoPlayer is paused, releasing its
WAKE_MODE_NETWORK locks -- so nothing kept the phone's radio awake. On a
locked, on-battery phone the WiFi power-saves within seconds and the CPU
dozes, stalling the 1 Hz liveness poll to the renderer and the
queue-extend calls to the server. The poll then trips DROP_THRESHOLD and
playback falls back to a phone that also has no network: silence, while
the Sonos was streaming fine the whole time.

Diagnosed from logcat: ~12s after screen-off the phone logged
"Unable to resolve host minstrel.fabledsword.com" (its own DNS, not the
server), the extend aborted (1/58 appended), then the drop tripped and
the local fallback came up active=null. USB charging masks it (no Doze
while charging), which is why it only bit on battery.

Add CastNetworkLock: a high-perf/low-latency WifiLock + partial WakeLock
acquired when a UPnP route goes active and released on drop/switch-back
(every teardown path funnels through holder.set(null) -> onActiveChanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bvandeusen merged commit 9550d8daaf into main 2026-06-12 19:45:44 -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#97