fix(android): declare WAKE_LOCK — the stream's Wi-Fi locks never actually engaged

WifiLock.acquire() enforces the WAKE_LOCK permission, which the manifest never
declared — every acquisition since the first Wi-Fi lock shipped threw
SecurityException, silently swallowed by a bare runCatching. The phone's own
accounting proved it (dumpsys wifi: high_perf/low_latency active_time_ms = 0
across weeks of streams): every on-device session ran with Wi-Fi power save
fully active, whatever the code intended. Verified live after the fix: both
locks registered in WifiLockManager, mPowerSaveDisableRequests=2, ping RTT to
the streaming phone 3.8 ms avg. A failed acquire now logs loudly — this class
of failure must never be invisible again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 08:07:42 +02:00
parent 8f06a4334e
commit dcef3c8600
2 changed files with 16 additions and 3 deletions
@@ -13,6 +13,12 @@
reception needs it (also an OEM Wi-Fi power-save hedge). --> reception needs it (also an OEM Wi-Fi power-save hedge). -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- WifiLock.acquire() ENFORCES this (a normal permission, granted at install). Without it the
stream's Wi-Fi locks throw SecurityException and power save stays on: downlink delivery
clumps at beacon intervals — hundreds of ms of latency mush + periodic whole-frame loss.
Its absence went unnoticed for weeks because the acquire was wrapped in a silent
runCatching (now logged). -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket. <!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
Harmless to declare on earlier releases. --> Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" /> <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
@@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import android.util.Log
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager import android.view.WindowManager
@@ -126,8 +127,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent // - FULL_HIGH_PERF covers older releases — it is deprecated AND a documented no-op on recent
// Android, which is exactly why it can't be the only lock (a lesson learned: holding just // Android, which is exactly why it can't be the only lock (a lesson learned: holding just
// HIGH_PERF left power save fully active on Android 13+). // HIGH_PERF left power save fully active on Android 13+).
// Needs no permission beyond ACCESS_WIFI_STATE (declared). Non-reference-counted: one explicit // acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud:
// acquire/release each. // a silent runCatching hid the missing permission for weeks (dumpsys wifi showed
// low_latency_active_time_ms=0 across every "locked" stream). Non-reference-counted: one
// explicit acquire/release each.
val wifiLocks = remember(handle) { val wifiLocks = remember(handle) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
?: return@remember emptyList<WifiManager.WifiLock>() ?: return@remember emptyList<WifiManager.WifiLock>()
@@ -144,7 +147,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
DisposableEffect(handle) { DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
wifiLocks.forEach { runCatching { it.acquire() } } wifiLocks.forEach { lock ->
runCatching { lock.acquire() }.onFailure { e ->
Log.w("punktfunk", "WifiLock acquire failed — power save stays ON: $lock", e)
}
}
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) — // HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API // the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API
// 30+. Part of the experimental low-latency stack. // 30+. Part of the experimental low-latency stack.