From 8f06a4334e4dd23dd6acdcf04e02000f22a59c60 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 07:34:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(android):=20hold=20BOTH=20Wi-Fi=20locks=20w?= =?UTF-8?q?hile=20streaming=20=E2=80=94=20HIGH=5FPERF=20alone=20is=20a=20n?= =?UTF-8?q?o-op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline stream held only WIFI_MODE_FULL_HIGH_PERF, which is deprecated AND non-functional on recent Android — so with the low-latency toggle off (the default) Wi-Fi power save stayed fully active: downlink delivery clumped at beacon intervals (a few hundred ms of latency mush, sawtoothing bitrate) and the AP's power-save buffer periodically overflowed, killing whole frames every few seconds (the host log's alternating loss_ppm=0/50000). Now every stream holds FULL_LOW_LATENCY (API 29+, the only effective power-save disable; foreground + screen-on, which a stream always is) AND FULL_HIGH_PERF (covers older releases) — the same pair Moonlight holds. The experimental toggle no longer selects the lock mode. Also: declare tracing's "log" feature explicitly in the native crate (core transport warnings → logcat must not hinge on quinn's default features), and align the low-latency toggle's copy with its actual scope. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + .../main/kotlin/io/unom/punktfunk/Settings.kt | 10 ++--- .../io/unom/punktfunk/SettingsScreen.kt | 2 +- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 44 +++++++++++-------- clients/android/native/Cargo.toml | 8 ++++ clients/android/native/src/lib.rs | 5 ++- 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83290f80..9e234aac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2918,6 +2918,7 @@ dependencies = [ "ndk", "opus", "punktfunk-core", + "tracing", ] [[package]] diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 59891c62..0ba60a2d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -55,12 +55,12 @@ data class Settings( */ val libraryEnabled: Boolean = true, /** - * "Low-latency mode (experimental)" — the master switch over the whole latency overhaul: decoder + * "Low-latency mode (experimental)" — the master switch over the latency overhaul: decoder * ranking + per-SoC vendor keys + the async decode loop (native), pipeline thread boosts + ADPF - * max-performance, game-tagged AAudio, DSCP marking on the media sockets, the Wi-Fi low-latency - * lock, HDMI ALLM, and the forced TV mode switch. Off (default): the original pre-overhaul - * pipeline, kept byte-for-byte as the known-good baseline — the overhaul regressed badly on some - * phones, so it's opt-in until it's proven per-device. + * max-performance, game-tagged AAudio, DSCP marking on the media sockets, HDMI ALLM, and the + * forced TV mode switch. (The Wi-Fi locks are NOT part of this — both are always held while + * streaming; see StreamScreen.) Off (default): the original decode pipeline, kept as the + * known-good baseline until the aggressive stack is proven per-device. */ val lowLatencyMode: Boolean = false, /** diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index efd17124..d3dc731d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -328,7 +328,7 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an ToggleRow( title = "Low-latency mode (experimental)", subtitle = "Aggressive decoder and system tuning (per-device decoder selection, async " + - "decode, Wi-Fi and HDMI hints). Can lower latency, but may stutter or glitch on " + + "decode, HDMI game mode). Can lower latency, but may stutter or glitch on " + "some devices — turn off if the stream misbehaves.", checked = s.lowLatencyMode, onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) }, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 8ce26d40..8dc852f3 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -65,9 +65,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // Touch model is fixed per session (re-keys the gesture handler below if it ever changes). val touchMode = initialSettings.touchMode // "Low-latency mode (experimental)" master toggle, resolved once for the session. Off (the - // default) runs the original pre-overhaul pipeline; on enables the whole aggressive stack — - // decoder ranking + vendor keys + async loop (native side), the Wi-Fi low-latency lock and - // HDMI ALLM below, game-tagged audio, and DSCP marking (applied earlier, at connect). + // default) runs the original decode pipeline; on enables the aggressive stack — decoder + // ranking + vendor keys + async loop (native side), HDMI ALLM below, game-tagged audio, and + // DSCP marking (applied earlier, at connect). val lowLatencyMode = initialSettings.lowLatencyMode // TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream // refresh; a phone/tablet gets the softer seamless frame-rate hint instead. @@ -117,26 +117,34 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit. val closed = remember { AtomicBoolean(false) } - // A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its - // power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API - // 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission - // beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release. - // Part of the experimental low-latency stack — not created at all when the toggle is off. - val wifiLock = remember(handle) { - if (!lowLatencyMode) return@remember null + // Wi-Fi locks held for the stream's duration — BOTH of them, unconditionally (Moonlight does + // the same). Without an effective lock, Wi-Fi power save batches downlink delivery into + // beacon-interval clumps: hundreds of ms of latency mush, sawtoothing bitrate, and periodic + // whole-frame loss when the AP's power-save buffer overflows (all observed live on a phone). + // - FULL_LOW_LATENCY (API 29+) is the only lock that actually disables power save on modern + // Android; it needs the app foreground + screen on, which a stream always is. + // - 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 + // HIGH_PERF left power save fully active on Android 13+). + // Needs no permission beyond ACCESS_WIFI_STATE (declared). Non-reference-counted: one explicit + // acquire/release each. + val wifiLocks = remember(handle) { val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager - val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - WifiManager.WIFI_MODE_FULL_LOW_LATENCY - } else { + ?: return@remember emptyList() + buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "punktfunk:stream-ll") + ?.let(::add) + } @Suppress("DEPRECATION") - WifiManager.WIFI_MODE_FULL_HIGH_PERF - } - wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) } + wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "punktfunk:stream-hp") + ?.let(::add) + }.onEach { it.setReferenceCounted(false) } } DisposableEffect(handle) { window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - runCatching { wifiLock?.acquire() } + wifiLocks.forEach { runCatching { it.acquire() } } // 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 // 30+. Part of the experimental low-latency stack. @@ -175,7 +183,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window?.setPreferMinimalPostProcessing(false) } - runCatching { if (wifiLock?.isHeld == true) wifiLock.release() } + wifiLocks.forEach { runCatching { if (it.isHeld) it.release() } } // Release the landscape lock so the rest of the app follows the device/system again. activity?.requestedOrientation = priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED diff --git a/clients/android/native/Cargo.toml b/clients/android/native/Cargo.toml index a4f92725..24ada0dd 100644 --- a/clients/android/native/Cargo.toml +++ b/clients/android/native/Cargo.toml @@ -31,6 +31,14 @@ mdns-sd = "0.20" # via `ndk`, the Opus codec) is only pulled in for the real `*-linux-android` targets. [target.'cfg(target_os = "android")'.dependencies] android_logger = "0.14" +# Feature bridge, no code here: punktfunk-core logs through `tracing`, but this client only +# installs `android_logger` (a `log` backend). Core transport warnings (e.g. "UDP socket buffer +# capped well below target") reach logcat only via tracing's "log" feature, which forwards events +# as `log` records when no tracing subscriber is set (always, here). Today that feature happens to +# be enabled transitively — quinn's default `log` feature unifies `tracing/log` onto the whole +# graph — but nothing about this client's logging should hinge on a QUIC crate's default feature +# set, so declare it explicitly. +tracing = { version = "0.1", default-features = false, features = ["std", "log"] } # NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback). # Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode + # audio run entirely in Rust on native threads (the "no async on the hot path" invariant). diff --git a/clients/android/native/src/lib.rs b/clients/android/native/src/lib.rs index 25dee21c..d9dbf1ee 100644 --- a/clients/android/native/src/lib.rs +++ b/clients/android/native/src/lib.rs @@ -44,7 +44,10 @@ mod stats; mod wol; /// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the -/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build. +/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures) +/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied +/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever +/// installed. Android-only — there is no JVM (and no logcat) on the host build. #[cfg(target_os = "android")] #[no_mangle] pub extern "system" fn JNI_OnLoad(