From 53c8eefa9972270483bd8c701ec166c24ef62e6b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 6 Jul 2026 23:58:53 +0200 Subject: [PATCH 1/8] =?UTF-8?q?fix(client-linux):=20break=20the=20FlowBox?= =?UTF-8?q?=20activation=20signal=20cycle=20=E2=80=94=20stack=20overflow?= =?UTF-8?q?=20on=20every=20host-card=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `child-activated` (fired by a pointer click) was bridged to `child.activate()` so each card's own connect handler (wired on the child's `activate` signal) would run. But `child.activate()` runs `GtkFlowBoxChild`'s default handler, which re-emits `child-activated` on the FlowBox — bouncing straight back into the same closure. Unguarded, that ping-pong recursed forever, overflowing the stack on every single host-card click or Enter-key activation (confirmed live via coredump/gdb: 43k+ stack frames of gobject signal emission, and the `fatal runtime error: stack overflow, aborting` in the crash log). A re-entrancy flag breaks the cycle after the one real activation. Added a regression test that wires the identical FlowBox/FlowBoxChild signal cycle against a real display and asserts it returns instead of recursing — it reproduces the exact stack overflow against the old code. Co-Authored-By: Claude Sonnet 5 --- clients/linux/src/ui_hosts.rs | 59 ++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index fc7d8c85..f72bf9fb 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -162,9 +162,20 @@ pub fn new(settings: Rc>, cbs: HostsCallbacks) -> HostsUi { // A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never // the child's own `activate` signal — so bridge it back to the child, where each card wires // its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead. + // + // `child.activate()` in turn runs `GtkFlowBoxChild`'s own default handler, which re-emits + // `child-activated` on the FlowBox — bouncing straight back into this closure. Unguarded, + // that ping-pong recurses forever and overflows the stack on every single card click/Enter + // (a real crash seen live, not hypothetical); the re-entrancy flag breaks the cycle after + // the one real activation. for flow in [&saved_flow, &disc_flow] { - flow.connect_child_activated(|_, child| { + let activating = std::cell::Cell::new(false); + flow.connect_child_activated(move |_, child| { + if activating.replace(true) { + return; + } child.activate(); + activating.set(false); }); } @@ -720,3 +731,49 @@ fn add_host_dialog(state: &Rc) { } dialog.present(Some(&state.stack)); } + +#[cfg(test)] +mod tests { + use adw::prelude::*; + use std::cell::Cell; + use std::rc::Rc; + + // Reproduces the exact FlowBox/FlowBoxChild wiring from `new()`: `child-activated` bridges + // to `child.activate()`, whose own default handler re-emits `child-activated` on the + // FlowBox — that ping-pong recursed forever (stack overflow on every host-card click/Enter) + // until the re-entrancy guard was added. This exercises the *real* GTK signal cycle, not a + // simulation of it, so it fails the same way the shipped bug did if the guard regresses. + #[test] + #[ignore = "needs a Wayland/X display"] + fn flow_box_activation_bridge_does_not_recurse() { + assert!(gtk::init().is_ok(), "no display"); + + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .build(); + let activating = Cell::new(false); + flow.connect_child_activated(move |_, child| { + if activating.replace(true) { + return; + } + child.activate(); + activating.set(false); + }); + + let child = gtk::FlowBoxChild::new(); + flow.insert(&child, -1); + let fired = Rc::new(Cell::new(0u32)); + { + let fired = fired.clone(); + child.connect_activate(move |_| fired.set(fired.get() + 1)); + } + + // What a pointer click with `activate_on_single_click` does internally: emit + // `child-activated` directly on the FlowBox. A regression here overflows the stack + // instead of returning. + flow.emit_by_name::<()>("child-activated", &[&child]); + + assert_eq!(fired.get(), 1, "the per-card handler should fire exactly once"); + } +} From e788d0de84021ee0c40719a5a48cb1907303c905 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 00:21:26 +0200 Subject: [PATCH 2/8] feat(client-linux): log the release/disconnect keyboard chords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ctrl+Alt+Shift+Q/D handlers had no tracing, so a report of "the disconnect shortcut doesn't work" was unverifiable from logs alone — live tracing (added temporarily, then trimmed to these two lines) showed the chord, `disconnect_quit()`, and the session teardown all firing correctly and instantly every time; the confusion traced back to the (now-fixed) FlowBox click crash having kept everyone from ever reaching a live session to test the shortcut with in the first place. Keep the two low-noise, deliberate-action log lines for the next time this comes up; drop the per-keystroke debug trace used to diagnose it, which would otherwise fire on every key during a stream. Co-Authored-By: Claude Sonnet 5 --- clients/linux/src/ui_stream.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index 07f86d94..fed3dc3a 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -806,6 +806,7 @@ fn attach_keyboard( | gdk::ModifierType::ALT_MASK | gdk::ModifierType::SHIFT_MASK; if state.contains(chord) && keyval.to_lower() == gdk::Key::q { + tracing::info!(captured = cap.captured.get(), "chord: Ctrl+Alt+Shift+Q (release/engage)"); if cap.captured.get() { cap.release(); } else { @@ -816,6 +817,7 @@ fn attach_keyboard( // Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host, // the capture toggle alone can't end a stream, so this is the keyboard's explicit exit. if state.contains(chord) && keyval.to_lower() == gdk::Key::d { + tracing::info!("chord: Ctrl+Alt+Shift+D (disconnect) — releasing capture + quitting"); cap.release(); // Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down // immediately instead of holding the keep-alive linger for a reconnect. From 912d7de2e69a899f85e84e50c40f5974393d29de Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 00:22:46 +0200 Subject: [PATCH 3/8] style(linux): rustfmt drift from the last two commits Co-Authored-By: Claude Sonnet 5 --- clients/linux/src/ui_hosts.rs | 6 +++++- clients/linux/src/ui_stream.rs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index f72bf9fb..ecf98381 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -774,6 +774,10 @@ mod tests { // instead of returning. flow.emit_by_name::<()>("child-activated", &[&child]); - assert_eq!(fired.get(), 1, "the per-card handler should fire exactly once"); + assert_eq!( + fired.get(), + 1, + "the per-card handler should fire exactly once" + ); } } diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index fed3dc3a..91cb8796 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -806,7 +806,10 @@ fn attach_keyboard( | gdk::ModifierType::ALT_MASK | gdk::ModifierType::SHIFT_MASK; if state.contains(chord) && keyval.to_lower() == gdk::Key::q { - tracing::info!(captured = cap.captured.get(), "chord: Ctrl+Alt+Shift+Q (release/engage)"); + tracing::info!( + captured = cap.captured.get(), + "chord: Ctrl+Alt+Shift+Q (release/engage)" + ); if cap.captured.get() { cap.release(); } else { From eea23c5647167fd4dd8eae131071763381916083 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 07:34:24 +0200 Subject: [PATCH 4/8] fix(core,host): make the native data plane survive real Wi-Fi links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused live on a phone at 100 Mbps (stream stuck seconds behind, then oscillating): a stack of transport defects, each amplifying the next. - MTU-safe shards: shard_payload 1452 overshot the IPv4/1500 budget (the old math forgot the 40 B header + 24 B crypto ride inside the UDP payload and counted IP+UDP as 8 B) — the kernel silently split EVERY video datagram into two IP fragments, doubling per-datagram loss on Wi-Fi. New config::mtu1500_shard_payload() = 1408 (1472 sealed = the exact ceiling), negotiated in the Welcome, pinned by a unit test. - Android batched I/O: recv/send batching was cfg(linux); Android is target_os="android" and silently fell back to a syscall per datagram. The libc crate binds neither recvmmsg/sendmmsg nor mmsghdr for Android, so a local bionic extern binding provides them (API 21+, floor is 28); cbindgen excludes them from the C header. The pump/runtime threads also get the Apple-QoS analogue on Android: nice −8 (below the decode thread's −10). - Latency-bounded receive: packets are consumed strictly in order at exactly the arrival rate, so a standing queue (Wi-Fi stall, power-save clumping) NEVER drains — observed as a stream permanently 6-7 s behind with both 32 MB socket buffers full. The pump now flushes the entire backlog (Session::flush_backlog: discard ring + kernel queue at memcpy speed, reset the reassembler) and requests a keyframe when frames keep completing > 400 ms behind the skew-corrected capture clock (30 consecutive, 2 s cooldown, logged). - Time-based loss window: the reassembler declared an incomplete frame lost a fixed 4 INDICES behind the newest — 33 ms at 120 fps, inside normal Wi-Fi retry/reorder timescales, so merely-late frames were pruned every few seconds, each costing a recovery-IDR burst + an inflated loss report. Now 120 ms of capture time (LOSS_WINDOW_NS), same fuse at every refresh rate, with a 64-index hard cap bounding memory against hostile pts. - Adaptive-FEC hysteresis: the controller was memoryless — one clean 750 ms report dropped FEC from 8 % straight back to the 1 % floor, so periodic burst loss (Wi-Fi scan / BT coexistence beats) always hit an unprotected stream and ping-ponged 1↔8 % with a frozen frame per cycle (observed in the host log as alternating loss_ppm=0/50000). Attack stays instant; decay is now one point per clean report. Verified: full core suite (incl. new flush + time-window tests) on macOS + Linux, host release build, arm64 cargo-ndk build, and a 30 s wired probe run at 2800x1260@120 — 3559/3559 frames, zero loss, capture→received p50 5.3 ms (host 5.1 + network 0.3). Co-Authored-By: Claude Fable 5 --- crates/punktfunk-core/benches/pipeline.rs | 2 +- crates/punktfunk-core/cbindgen.toml | 9 +- crates/punktfunk-core/src/client.rs | 84 ++++++++++- crates/punktfunk-core/src/config.rs | 26 ++++ crates/punktfunk-core/src/packet.rs | 168 ++++++++++++++++++--- crates/punktfunk-core/src/session.rs | 39 +++++ crates/punktfunk-core/src/transport/udp.rs | 69 +++++++-- crates/punktfunk-core/tests/loopback.rs | 42 ++++++ crates/punktfunk-host/src/punktfunk1.rs | 31 +++- 9 files changed, 418 insertions(+), 52 deletions(-) diff --git a/crates/punktfunk-core/benches/pipeline.rs b/crates/punktfunk-core/benches/pipeline.rs index 4abc929d..4fc48bf0 100644 --- a/crates/punktfunk-core/benches/pipeline.rs +++ b/crates/punktfunk-core/benches/pipeline.rs @@ -16,7 +16,7 @@ use punktfunk_core::session::Session; use punktfunk_core::transport::loopback_pair; const TAG_LEN: usize = 16; // AES-GCM authentication tag -const SHARD: usize = 1452; // ~one MTU-sized data shard +const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard fn cfg(role: Role, scheme: FecScheme) -> Config { Config { diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 4bfbb531..0e263da0 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -13,10 +13,11 @@ documentation_style = "c99" parse_deps = false [export] -# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of -# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header, -# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile. -exclude = ["MsghdrX", "recvmsg_x"] +# Internal platform-only FFI — NOT part of the C ABI. cbindgen otherwise sweeps the foreign +# imports and their #[repr(C)] structs into the header, where socklen_t/ssize_t/iovec/msghdr are +# undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs +# `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module). +exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] [export.rename] "InputEvent" = "PunktfunkInputEvent" diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 5f560932..7141c1fd 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -123,6 +123,24 @@ pub struct ProbeOutcome { /// (display freshness over completeness — FEC/keyframes recover). const FRAME_QUEUE: usize = 16; +/// Backlog latency bound: when completed frames keep arriving further than this behind the host's +/// capture clock (skew-corrected), the pump flushes the receive backlog +/// ([`Session::flush_backlog`]) and requests a keyframe instead of playing that far behind +/// forever. Deliberately generous — an interactive stream is unusable well before 400 ms, but the +/// bound must sit safely above the skew handshake's own error (≈ RTT/2) plus normal delivery +/// jitter so a healthy stream can never trip it. +const FLUSH_LATENCY: Duration = Duration::from_millis(400); + +/// How many CONSECUTIVE over-bound frames arm a flush (~0.5 s at 60 fps). A genuine standing queue +/// puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears within a +/// frame or two and never reaches the count. +const FLUSH_AFTER_FRAMES: u32 = 30; + +/// Minimum spacing between backlog flushes, so a bottleneck that instantly rebuilds the queue (a +/// link that can't sustain the bitrate at all) degrades into a periodic skip + a logged warning +/// instead of a continuous flush/keyframe storm. +const FLUSH_COOLDOWN: Duration = Duration::from_secs(2); + /// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging /// embedder drops the newest packet (the audio renderer conceals the gap). const AUDIO_QUEUE: usize = 64; @@ -248,8 +266,9 @@ pub struct NativeClient { /// std channels these worker threads feed; if the producers run at the default QoS, the /// kernel sees a high-QoS thread parked waiting on a lower-QoS one and the Thread Performance /// Checker flags a priority inversion. Matching the producers to the consumers' QoS removes -/// the inversion without slowing the Swift side. No-op off Apple (the Linux client/host don't -/// run a QoS scheduler, and `punktfunk-probe` doesn't care). +/// the inversion without slowing the Swift side. Android gets a nice-level analogue (see the +/// android arm below); a no-op elsewhere (the Linux client/host don't run a QoS scheduler, and +/// `punktfunk-probe` doesn't care). #[cfg(target_vendor = "apple")] fn pin_thread_user_interactive() { // SAFETY: sets only the current thread's QoS class — always valid to call. @@ -257,9 +276,33 @@ fn pin_thread_user_interactive() { libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0); } } -#[cfg(not(target_vendor = "apple"))] +/// Android analogue of the Apple QoS pin: raise the calling thread to nice −8 (the framework's +/// URGENT_DISPLAY band — apps may set negative nice on their own threads). At default nice 0 the +/// EAS scheduler happily parks the data-plane pump (UDP receive + decrypt + FEC — a thread that +/// sleeps between bursts) on a down-clocked little core, and a few ms of scheduling delay during a +/// keyframe burst overflows the socket receive buffer → wire loss the link never saw. −8 keeps the +/// pipeline below the decode thread's −10 (the display path still wins). Best-effort, like Apple's. +#[cfg(target_os = "android")] +fn pin_thread_user_interactive() { + // SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; a refusal is + // reported via the return value (ignored — a missed boost, not an error on the data path). + unsafe { + let tid = libc::gettid(); + let _ = libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8); + } +} +#[cfg(not(any(target_vendor = "apple", target_os = "android")))] fn pin_thread_user_interactive() {} +/// Wall-clock now in nanoseconds (CLOCK_REALTIME basis), to compare against the host-stamped +/// capture `pts_ns` after the skew offset is applied — the same latency math the stats HUDs use. +fn now_realtime_ns() -> i128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as i128) + .unwrap_or(0) +} + /// The calling thread's kernel id, for hot-thread performance hints (the Android client's ADPF /// session today; the consumer is platform-specific). Linux/Android expose `gettid`; elsewhere /// there's nothing to hint with, so registration is a no-op. @@ -1196,6 +1239,11 @@ async fn worker_main(args: WorkerArgs) { const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750); let mut last_report = Instant::now(); let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64); + // Backlog latency bound (see FLUSH_LATENCY): consecutive over-bound frames + the last + // flush, for the cooldown. Armed only when the skew handshake succeeded (offset ≠ 0) — + // without it the host and client clocks aren't comparable and the bound would misfire. + let mut stale_frames: u32 = 0; + let mut last_flush: Option = None; while !pump_shutdown.load(Ordering::SeqCst) { // Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery // loop, and (during a speed test) the packet-level receive counters for the throughput @@ -1230,6 +1278,36 @@ async fn worker_main(args: WorkerArgs) { if frame.flags & FLAG_PROBE as u32 != 0 { continue; // speed-test filler, not video — measured via the counters above } + // Latency bound: a standing receive queue (pump transiently outpaced, a Wi-Fi + // stall, power-save clumping) never drains by itself — the pump consumes at + // exactly the arrival rate, so once behind, the stream stays behind for good + // (observed live: stuck 6–7 s). When frames keep completing over the bound, + // discard the whole backlog and ask for a keyframe: one visible skip instead of + // a permanently unusable stream. Suspended during a speed test (the probe + // MEASURES a saturated queue; flushing would corrupt its receive counters). + if clock_offset_ns != 0 && !probe_active { + let lat_ns = + now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128; + if lat_ns > FLUSH_LATENCY.as_nanos() as i128 { + stale_frames += 1; + } else { + stale_frames = 0; + } + if stale_frames >= FLUSH_AFTER_FRAMES + && last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN) + { + stale_frames = 0; + last_flush = Some(Instant::now()); + let flushed = session.flush_backlog().unwrap_or(0); + let _ = ctrl_tx.send(CtrlRequest::Keyframe); + tracing::warn!( + behind_ms = lat_ns / 1_000_000, + flushed_datagrams = flushed, + "receive backlog exceeded the latency bound — flushed to live" + ); + continue; // this frame is part of the stale past — don't render it + } + } let _ = frame_tx.try_send(frame); } Err(PunktfunkError::NoFrame) => { diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index ad6ea7e9..cbe623ed 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -256,6 +256,19 @@ pub const fn max_shard_payload() -> usize { MAX_DATAGRAM_BYTES - HEADER_LEN - CRYPTO_OVERHEAD } +/// Largest **even** shard payload whose sealed wire datagram still fits an unfragmented IPv4/UDP +/// packet on a standard 1500-byte MTU: `1500 − 20 (IPv4) − 8 (UDP) − HEADER_LEN − CRYPTO_OVERHEAD` +/// = 1408. Hosts should default `shard_payload` to this: one byte more and the kernel silently +/// splits EVERY video datagram into two IP fragments (a full frame plus a runt) — either fragment +/// lost = the datagram lost, roughly doubling per-datagram loss on Wi-Fi and eating straight into +/// FEC's recovery margin, plus per-pair kernel reassembly and runt airtime at line rate. (Exactly +/// what the previous hardcoded 1452 did: its MTU math forgot the punktfunk header + crypto ride +/// inside the UDP payload and counted the IP+UDP headers as 8 bytes instead of 28.) +pub const fn mtu1500_shard_payload() -> usize { + let p = 1500 - 20 - 8 - HEADER_LEN - CRYPTO_OVERHEAD; + p - p % 2 // FEC requires even shards +} + /// Everything needed to construct a [`Session`](crate::session::Session). /// /// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized @@ -392,6 +405,19 @@ mod tests { assert!(c.validate().is_err()); } + /// Pin the 1500-MTU wire math: the sealed datagram (header + shard + crypto) at the MTU-safe + /// shard payload must be ≤ 1472 (1500 − IPv4 20 − UDP 8), and one shard-step (+2) above must + /// not — the regression that shipped as 1452 and IP-fragmented every video datagram. + #[test] + fn mtu1500_shard_payload_never_fragments() { + let p = mtu1500_shard_payload(); + assert_eq!(p % 2, 0, "FEC requires even shards"); + assert!(p <= max_shard_payload()); + let wire = HEADER_LEN + p + CRYPTO_OVERHEAD; + assert!(wire <= 1472, "sealed datagram {wire} B would IP-fragment"); + assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1472, "not maximal"); + } + #[test] fn rejects_block_exceeding_scheme_ceiling() { let mut c = Config::p1_defaults(Role::Host); // Gf8, ceiling 255 diff --git a/crates/punktfunk-core/src/packet.rs b/crates/punktfunk-core/src/packet.rs index 78cd1de2..a2c712df 100644 --- a/crates/punktfunk-core/src/packet.rs +++ b/crates/punktfunk-core/src/packet.rs @@ -43,8 +43,29 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN; /// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`. pub const MAX_DATAGRAM_BYTES: usize = 2048; -/// How many frames behind the newest the reassembler keeps before pruning stragglers. -const REORDER_WINDOW: u32 = 16; +/// How far behind the newest frame's capture pts an INCOMPLETE frame may sit before it is +/// declared lost (counted in `frames_dropped`, which triggers the client's recovery-keyframe +/// request). TIME-based, not frame-count-based, so the fuse is the same at every refresh rate: a +/// fixed index window is refresh-relative (4 frames = 66 ms at 60 fps but only 33 ms at 120 fps — +/// inside normal Wi-Fi retry/block-ack reorder timescales, where a delayed-not-lost shard can +/// trail newer frames). Observed live at 120 fps: the too-tight fuse declared merely-late frames +/// dead every few seconds, and each false loss cost a recovery-IDR burst + an inflated loss report +/// (FEC churn) — a self-sustaining latency/bitrate oscillation. 120 ms rides safely above radio +/// retry jitter while still detecting a real loss ~2× faster than the original 16-frame window did +/// at 60 fps. +const LOSS_WINDOW_NS: u64 = 120_000_000; + +/// Hard cap on how many frame INDICES behind the newest an incomplete frame may sit, whatever its +/// pts claims — bounds the reassembler's memory against a corrupt/hostile pts (which +/// [`LOSS_WINDOW_NS`] alone would trust) and against pathologically high frame rates. At 120 fps, +/// 120 ms ≈ 14 indices, so 64 leaves ample slack up to ~500 fps. +const HARD_LOSS_WINDOW: u32 = 64; + +/// How many frames behind the newest the reassembler remembers emitted/abandoned frame indices +/// (`completed`), so a straggler shard can neither resurrect an abandoned frame nor re-open an +/// emitted one. Must cover at least [`HARD_LOSS_WINDOW`]: stragglers can trickle in later than the +/// loss verdict. +const REORDER_WINDOW: u32 = 64; /// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable. #[repr(C)] @@ -274,7 +295,10 @@ pub struct Reassembler { /// Recently-emitted frames, so stray/late shards can't resurrect them. Pruned to /// the reorder window alongside `frames`. completed: HashSet, - newest_frame: Option, + /// The newest frame seen, as `(frame_index, capture pts)` — the loss-window anchor: an + /// incomplete frame is declared lost once it sits [`LOSS_WINDOW_NS`] behind this pts (or + /// [`HARD_LOSS_WINDOW`] indices, whichever trips first). + newest_frame: Option<(u32, u64)>, } impl Reassembler { @@ -344,12 +368,12 @@ impl Reassembler { } let payload = pkt[HEADER_LEN..HEADER_LEN + shard_bytes].to_vec(); - self.advance_window(hdr.frame_index, stats); + self.advance_window(hdr.frame_index, hdr.pts_ns, stats); // Drop shards for frames we've already emitted (e.g. the recovery shards of a // frame that completed early via the all-originals-present fast path) or that - // have fallen out of the reorder window. - if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index) { + // have fallen out of the loss window. + if self.completed.contains(&hdr.frame_index) || self.is_stale(hdr.frame_index, hdr.pts_ns) { drop(stats); return Ok(None); } @@ -461,19 +485,31 @@ impl Reassembler { Ok(None) } - /// Track the newest frame and prune stragglers that fell out of the reorder window - /// (counting them as dropped). - fn advance_window(&mut self, frame_index: u32, stats: &StatsCounters) { - let newest = match self.newest_frame { + /// Track the newest frame, declare incomplete frames that fell out of the loss window + /// ([`LOSS_WINDOW_NS`] behind the newest pts, or [`HARD_LOSS_WINDOW`] indices) lost — counting + /// them dropped, which is what drives the client's recovery-keyframe request — and prune the + /// completed-index memory to [`REORDER_WINDOW`]. + fn advance_window(&mut self, frame_index: u32, pts_ns: u64, stats: &StatsCounters) { + let (newest, newest_pts) = match self.newest_frame { // `frame_index` is newer iff it's within the forward half of the index space. - Some(n) if frame_index.wrapping_sub(n) > u32::MAX / 2 => n, - _ => frame_index, + Some((n, p)) if frame_index.wrapping_sub(n) > u32::MAX / 2 => (n, p), + _ => (frame_index, pts_ns), }; - self.newest_frame = Some(newest); + self.newest_frame = Some((newest, newest_pts)); let before = self.frames.len(); - self.frames - .retain(|&idx, _| newest.wrapping_sub(idx) <= REORDER_WINDOW); + let completed = &mut self.completed; + self.frames.retain(|&idx, f| { + let keep = newest.wrapping_sub(idx) <= HARD_LOSS_WINDOW + && newest_pts.saturating_sub(f.pts_ns) <= LOSS_WINDOW_NS; + if !keep { + // Remember the abandoned index so a straggler shard is dropped (below, and in + // `push`) instead of resurrecting the frame — which would re-allocate its buffers + // and double-count the drop when it aged out again. + completed.insert(idx); + } + keep + }); let pruned = before - self.frames.len(); if pruned > 0 { StatsCounters::add(&stats.frames_dropped, pruned as u64); @@ -482,13 +518,29 @@ impl Reassembler { .retain(|&idx| newest.wrapping_sub(idx) <= REORDER_WINDOW); } - /// True if `frame_index` lies behind the newest frame by more than the reorder - /// window (so its shards arrive too late to be useful). - fn is_stale(&self, frame_index: u32) -> bool { + /// Drop all in-flight state — every partially-assembled frame and the completed/abandoned + /// index memory — as if the session just started. Used by the client's backlog flush + /// ([`Session::flush_backlog`](crate::session::Session::flush_backlog)): after the socket + /// backlog is discarded wholesale, the partial frames here can never complete (their remaining + /// shards were just thrown away) and the window anchor (`newest_frame`) points into the + /// discarded past. + pub fn reset(&mut self) { + self.frames.clear(); + self.completed.clear(); + self.newest_frame = None; + } + + /// True if this packet's frame lies outside the loss window (behind the newest frame by more + /// than [`LOSS_WINDOW_NS`] of capture time or [`HARD_LOSS_WINDOW`] indices) — its shards + /// arrive too late to be useful, and accepting one would only create a frame buffer the next + /// [`advance_window`] immediately declares lost. + fn is_stale(&self, frame_index: u32, pts_ns: u64) -> bool { match self.newest_frame { - Some(n) => { + Some((n, newest_pts)) => { let behind = n.wrapping_sub(frame_index); - behind > REORDER_WINDOW && behind <= u32::MAX / 2 + behind <= u32::MAX / 2 + && (behind > HARD_LOSS_WINDOW + || newest_pts.saturating_sub(pts_ns) > LOSS_WINDOW_NS) } None => false, } @@ -585,6 +637,82 @@ mod tests { assert_eq!(stats.snapshot().packets_dropped, 1); } + /// The loss window is TIME-based: an incomplete frame survives newer frames arriving within + /// [`LOSS_WINDOW_NS`] of its capture pts (a 33 ms-late shard at 120 fps is late, not lost — + /// the old 4-INDEX window wrongly killed it), is declared lost once the newest pts moves past + /// the window (`frames_dropped`), and a straggler shard can't resurrect it afterwards. + #[test] + fn incomplete_frames_age_out_by_capture_time_not_frame_count() { + let mut r = Reassembler::new(limits()); + let coder = coder_for(FecScheme::Gf8); + let stats = StatsCounters::default(); + const FRAME_NS: u64 = 8_333_333; // 120 fps + + // Frame 0: one of its two shards arrives — incomplete. + let mut h = base_header(); + h.data_shards = 2; + h.frame_bytes = 32; + assert!(r + .push(&packet(h), coder.as_ref(), &stats) + .unwrap() + .is_none()); + + // Frames 1..=8 complete around it (well past the old 4-index window, inside 120 ms): + // frame 0 must still be alive — no drop counted. + for i in 1..=8u32 { + let mut h = base_header(); + h.frame_index = i; + h.pts_ns = i as u64 * FRAME_NS; + assert!(r + .push(&packet(h), coder.as_ref(), &stats) + .unwrap() + .is_some()); + } + assert_eq!(stats.snapshot().frames_dropped, 0); + + // Frame 0's second shard arrives 8 frames late (~66 ms at 120 fps) — completes fine. + let mut h = base_header(); + h.data_shards = 2; + h.frame_bytes = 32; + h.shard_index = 1; + assert!(r + .push(&packet(h), coder.as_ref(), &stats) + .unwrap() + .is_some()); + + // Frame 20: incomplete again; then a frame lands past the 120 ms window → declared lost. + let mut h = base_header(); + h.frame_index = 20; + h.pts_ns = 20 * FRAME_NS; + h.data_shards = 2; + h.frame_bytes = 32; + assert!(r + .push(&packet(h), coder.as_ref(), &stats) + .unwrap() + .is_none()); + let mut h = base_header(); + h.frame_index = 21; + h.pts_ns = 20 * FRAME_NS + LOSS_WINDOW_NS + 1; + assert!(r + .push(&packet(h), coder.as_ref(), &stats) + .unwrap() + .is_some()); + assert_eq!(stats.snapshot().frames_dropped, 1); + + // A straggler shard for the abandoned frame 20 is dropped, never resurrected. + let mut h = base_header(); + h.frame_index = 20; + h.pts_ns = 20 * FRAME_NS; + h.data_shards = 2; + h.frame_bytes = 32; + h.shard_index = 1; + assert!(r + .push(&packet(h), coder.as_ref(), &stats) + .unwrap() + .is_none()); + assert_eq!(stats.snapshot().frames_dropped, 1, "no double-count"); + } + #[test] fn rejects_wrong_shard_bytes_and_oversized_frame() { let coder = coder_for(FecScheme::Gf8); diff --git a/crates/punktfunk-core/src/session.rs b/crates/punktfunk-core/src/session.rs index 450f25ca..feafa28f 100644 --- a/crates/punktfunk-core/src/session.rs +++ b/crates/punktfunk-core/src/session.rs @@ -290,6 +290,45 @@ impl Session { } } + /// Client: discard the ENTIRE pending receive backlog — the current recv ring plus everything + /// queued in the kernel socket buffer — and reset the reassembler. Returns how many datagrams + /// were thrown away (counted into `packets_dropped`). + /// + /// This is the latency-bound escape hatch: the receive path has no other way to skip ahead. + /// Packets arrive strictly in order, so once a standing queue forms (the pump transiently + /// slower than the wire, a Wi-Fi stall, power-save delivery clumping), the client plays that + /// far behind FOREVER — it consumes at exactly the arrival rate, so the backlog never shrinks + /// (observed live: a stream stuck 6–7 s behind, socket buffers full end to end). Discarding + /// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in + /// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration + /// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender + /// outpacing the discard loop indefinitely. + pub fn flush_backlog(&mut self) -> Result { + if self.config.role != Role::Client { + return Err(PunktfunkError::InvalidArg( + "flush_backlog called on a host session", + )); + } + // The undelivered tail of the current ring is backlog too. + let mut flushed = self.recv_count.saturating_sub(self.recv_idx) as u64; + self.recv_count = 0; + self.recv_idx = 0; + if !self.recv_scratch.is_empty() { + for _ in 0..4096 { + let n = self + .transport + .recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?; + if n == 0 { + break; + } + flushed += n as u64; + } + } + self.reassembler.reset(); + StatsCounters::add(&self.stats.packets_dropped, flushed); + Ok(flushed) + } + /// Client: serialize and send one input event to the host. pub fn send_input(&mut self, event: &InputEvent) -> Result<()> { if self.config.role != Role::Client { diff --git a/crates/punktfunk-core/src/transport/udp.rs b/crates/punktfunk-core/src/transport/udp.rs index 0589edd0..0c6b48ce 100644 --- a/crates/punktfunk-core/src/transport/udp.rs +++ b/crates/punktfunk-core/src/transport/udp.rs @@ -1,10 +1,12 @@ //! Real UDP datagram transport — native sockets, no async runtime. //! //! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg` -//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever -//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send -//! across the frame interval (see `punktfunk1.rs::paced_submit`) so a real NIC doesn't drop a line-rate -//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux). +//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is +//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`]) +//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally +//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real +//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar +//! fallbacks for loopback and the remaining targets). use super::Transport; use crate::packet::MAX_DATAGRAM_BYTES; @@ -57,16 +59,51 @@ fn is_transient_io(e: &std::io::Error) -> bool { } } +/// `sendmmsg`/`recvmmsg` + `mmsghdr` for Android, where the `libc` crate binds only the syscall +/// number (`SYS_recvmmsg`) and neither the wrapper functions nor the struct — even though bionic +/// has exported both since API 21 (below our API-28 floor), and Rust's `target_os = "android"` is +/// NOT `"linux"`, so the batched paths below silently excluded Android and the client fell back to +/// one syscall per datagram. The struct layout is stable kernel ABI (`struct mmsghdr` in +/// `linux/socket.h`): a `msghdr` followed by the received byte count. +#[cfg(target_os = "android")] +mod android_mmsg { + #[repr(C)] + #[allow(non_camel_case_types)] + pub struct mmsghdr { + pub msg_hdr: libc::msghdr, + pub msg_len: libc::c_uint, + } + extern "C" { + pub fn sendmmsg( + sockfd: libc::c_int, + msgvec: *mut mmsghdr, + vlen: libc::c_uint, + flags: libc::c_int, + ) -> libc::c_int; + pub fn recvmmsg( + sockfd: libc::c_int, + msgvec: *mut mmsghdr, + vlen: libc::c_uint, + flags: libc::c_int, + timeout: *mut libc::timespec, + ) -> libc::c_int; + } +} +#[cfg(target_os = "android")] +use android_mmsg::{mmsghdr, recvmmsg, sendmmsg}; +#[cfg(target_os = "linux")] +use libc::{mmsghdr, recvmmsg, sendmmsg}; + /// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared /// by `send_batch` + `recv_batch` so the raw-pointer scaffolding lives in exactly one place. /// /// SAFETY (caller's): each returned header holds a raw pointer into `iovs`; the caller MUST keep /// `iovs` alive and unmoved for as long as the headers are passed to the syscall. -#[cfg(target_os = "linux")] -fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec { +#[cfg(any(target_os = "linux", target_os = "android"))] +fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec { iovs.iter_mut() .map(|iov| { - let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() }; + let mut h: mmsghdr = unsafe { std::mem::zeroed() }; h.msg_hdr.msg_iov = iov; h.msg_hdr.msg_iovlen = 1; h @@ -575,9 +612,9 @@ impl Transport for UdpTransport { /// no per-message address. The socket is non-blocking, so a full send buffer surfaces as a /// short count (or `EAGAIN` with nothing sent); we stop and report what went out rather than /// block or retry — the data plane is lossy + FEC-protected, and blocking would queue stale - /// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Non-Linux falls back to - /// the trait's scalar `send` loop (no `sendmmsg`). - #[cfg(target_os = "linux")] + /// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Other targets fall back + /// to the trait's scalar `send` loop (no `sendmmsg`). + #[cfg(any(target_os = "linux", target_os = "android"))] fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result { use std::os::fd::AsRawFd; const CHUNK: usize = 64; @@ -593,7 +630,7 @@ impl Transport for UdpTransport { }) .collect(); let mut hdrs = mmsghdrs(&mut iovs); - let n = unsafe { libc::sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) }; + let n = unsafe { sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) }; if n < 0 { let err = std::io::Error::last_os_error(); // Nothing fit in the send buffer (or a stale ICMP from a connected-socket blip) — @@ -723,9 +760,9 @@ impl Transport for UdpTransport { /// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking /// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and /// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching - /// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the - /// trait's scalar default. - #[cfg(target_os = "linux")] + /// `recv`'s oversized-drop. Android uses the local bionic binding (see [`android_mmsg`]). + /// Apple/BSD use the `recv`-loop override below; other non-unix the trait's scalar default. + #[cfg(any(target_os = "linux", target_os = "android"))] fn recv_batch(&self, out: &mut [Vec], lens: &mut [usize]) -> std::io::Result { use std::os::fd::AsRawFd; let fd = self.socket.as_raw_fd(); @@ -743,7 +780,7 @@ impl Transport for UdpTransport { .collect(); let mut hdrs = mmsghdrs(&mut iovs); let n = unsafe { - libc::recvmmsg( + recvmmsg( fd, hdrs.as_mut_ptr(), n_bufs as libc::c_uint, @@ -772,7 +809,7 @@ impl Transport for UdpTransport { /// batches; our client per-packet-allocated). It is still one syscall per datagram (a future /// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set /// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`. - #[cfg(all(unix, not(target_os = "linux")))] + #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] fn recv_batch(&self, out: &mut [Vec], lens: &mut [usize]) -> std::io::Result { // Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it // and falls through to the always-correct scalar loop below. diff --git a/crates/punktfunk-core/tests/loopback.rs b/crates/punktfunk-core/tests/loopback.rs index df8cabec..17e9aa77 100644 --- a/crates/punktfunk-core/tests/loopback.rs +++ b/crates/punktfunk-core/tests/loopback.rs @@ -112,6 +112,48 @@ fn lossless_stream_is_exact() { ); } +/// The client's latency-bound escape hatch: `flush_backlog` must discard every queued datagram +/// (counting them dropped), reset the reassembler so half-assembled frames from the flushed past +/// can't linger, and leave the session healthy — the next submitted frame recovers byte-exact. +#[test] +fn flush_backlog_discards_queue_and_recovers() { + let (host_tp, client_tp) = loopback_pair(0, 0); + let mut host = Session::new( + config(Role::Host, FecScheme::Gf16, false, 0), + Box::new(host_tp), + ) + .unwrap(); + let mut client = Session::new( + config(Role::Client, FecScheme::Gf16, false, 0), + Box::new(client_tp), + ) + .unwrap(); + + let frames = sample_frames(); + // Read one frame first so the client's recv ring exists and may hold an undelivered tail. + host.submit_frame(&frames[0], 0, 0).unwrap(); + client.poll_frame().unwrap(); + // Queue a multi-frame backlog, then flush it: everything pending is discarded. + for (i, f) in frames.iter().enumerate().skip(1) { + host.submit_frame(f, i as u64 * 1_000_000, 0).unwrap(); + } + let flushed = client.flush_backlog().unwrap(); + assert!(flushed > 0, "a queued backlog must be discarded"); + assert_eq!(client.stats().packets_dropped, flushed); + assert!( + matches!( + client.poll_frame(), + Err(punktfunk_core::PunktfunkError::NoFrame) + ), + "nothing pending after a flush" + ); + // The stream resumes cleanly: the next frame (the "recovery keyframe") completes byte-exact. + let recovery = vec![0xA5u8; 100_000]; + host.submit_frame(&recovery, 99_000_000, 0).unwrap(); + let got = client.poll_frame().expect("post-flush frame completes"); + assert_eq!(got.data, recovery); +} + #[test] fn input_round_trips_client_to_host() { let (host_tp, client_tp) = loopback_pair(0, 0); diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 96ee03cb..9e8fca14 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -26,7 +26,9 @@ #![deny(clippy::undocumented_unsafe_blocks)] use anyhow::{anyhow, Context, Result}; -use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role}; +use punktfunk_core::config::{ + mtu1500_shard_payload, CompositorPref, FecConfig, FecScheme, GamepadPref, Role, +}; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF}; use punktfunk_core::quic::{ @@ -969,11 +971,14 @@ async fn serve_session( fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START), max_data_per_block: 4096, }, - // ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24 - // crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even - // size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a - // future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU). - shard_payload: 1452, + // The largest even payload whose sealed datagram (header + shard + crypto) fits an + // unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling. + // The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP + // payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss + // on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. Negotiated, so the + // client follows. Jumbo (≈8900) is a future negotiated bump (needs MAX_DATAGRAM_BYTES + // raised + end-to-end 9000 MTU). + shard_payload: mtu1500_shard_payload() as u16, encrypt: true, key, salt: *b"pkf1", @@ -1092,8 +1097,18 @@ async fn serve_session( // send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC // is pinned via PUNKTFUNK_FEC_PCT. if adaptive_fec { - let target = adapt_fec(rep.loss_ppm); - let prev = fec_target_ctl.swap(target, Ordering::Relaxed); + // Fast attack, slow decay: jump straight to what the reported loss + // needs, but come DOWN only one point per clean report (~750 ms). The + // memoryless controller ping-ponged on periodic burst loss (Wi-Fi + // scans / BT coexistence, a burst every few seconds): a single clean + // window dropped FEC back to the floor, so every next burst hit an + // unprotected stream — an unrecoverable frame, a freeze, and a + // recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps + // the stream covered across the gap while still converging to FEC_MIN + // on a genuinely clean link. + let prev = fec_target_ctl.load(Ordering::Relaxed); + let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1)); + fec_target_ctl.store(target, Ordering::Relaxed); if prev != target { tracing::info!( loss_ppm = rep.loss_ppm, From 83b7c7adf55f1913932f4c8b795f9a979496aa01 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 07:34:42 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(android):=20console=20UI=20=E2=80=94?= =?UTF-8?q?=20per-controller=20glyphs,=20dialog=20scrolling,=20animated=20?= =?UTF-8?q?forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hint-bar glyphs now wear the driving controller's family (kit Gamepad.styleFor by USB vendor id → MainActivity.lastPadStyle, kept live by real input like lastPadIsGamepad): PlayStation pads get Canvas-drawn cross/circle/square/triangle shapes in the classic colours, Nintendo pads monochrome lettering, Xbox/Valve/unknown the coloured letter discs. Hint chars stay semantic (KEYCODE_BUTTON names); only the rendering changes. - The Options legend renders the pad's real Select-family button (SelectButtonGlyph): Xbox View windows, PlayStation Create capsule, Nintendo minus — instead of a bare capsule outline. - GamepadDialog: body + action stack scroll together (title pinned) with BringIntoViewRequester keeping the focused button visible — a 5-action host options dialog compressed/clipped its last button in short landscape windows because the pinned stack could not scroll. - Console form polish: shared animateConsoleFocus (bg/border cross-fade + spring scale) across settings rows / add-host fields / action rows; ConsoleSwitch (spring knob, tinting track) replaces On/Off text on toggle rows; choice values slide in the direction they were stepped (AnimatedContent + SizeTransform) with chevrons that fade in place; the focused row's detail unfolds via AnimatedVisibility; dialog buttons and keyboard keycaps cross-fade (keycaps at 90 ms for hold-to-repeat). - Console settings gain the "Low-latency mode" (Video) and "Auto-wake on connect" (Interface) rows, round-tripping with the touch settings. - Screenshot scene: StatsOverlay call updated to the 18-double layout + the new decoderLabel parameter (fixes the android-screenshots CI compile). Co-Authored-By: Claude Fable 5 --- .../io/unom/punktfunk/GamepadAddHostScreen.kt | 38 ++- .../kotlin/io/unom/punktfunk/GamepadChrome.kt | 222 ++++++++++++++++-- .../io/unom/punktfunk/GamepadDialogs.kt | 74 ++++-- .../unom/punktfunk/GamepadSettingsScreen.kt | 99 ++++++-- .../kotlin/io/unom/punktfunk/MainActivity.kt | 15 +- .../unom/punktfunk/screenshots/ShotScenes.kt | 17 +- .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 23 ++ 7 files changed, 412 insertions(+), 76 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt index e00a2d6c..7da0c8a2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -2,7 +2,8 @@ package io.unom.punktfunk import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row] @Composable private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale") + val visuals = animateConsoleFocus(active = focused || editing, editing = editing) val shape = RoundedCornerShape(14.dp) Row( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale } + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } .clip(shape) - .background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, @@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - @Composable private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale") + val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) + val labelColor by animateColorAsState( + if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + tween(160), + label = "addLabel", + ) Box( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale } + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } .clip(shape) - .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .padding(vertical = 14.dp), contentAlignment = Alignment.Center, @@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, - color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + color = labelColor, ) } } @@ -448,11 +454,19 @@ private fun KeyboardGrid( @Composable private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + // Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the + // trailing key must have faded before the cursor is two keys away — quick, but no longer a snap. + val bg by animateColorAsState( + if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF), + tween(90), + label = "keyBg", + ) + val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg") Box( modifier = modifier .height(if (compact) 34.dp else 44.dp) .clip(RoundedCornerShape(9.dp)) - .background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF)) + .background(bg) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, - color = if (focused) Color.Black else Color.White, + color = fg, textAlign = TextAlign.Center, ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index 61c89430..e7e4cefb 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -1,10 +1,14 @@ package io.unom.punktfunk +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect +import io.unom.punktfunk.kit.Gamepad import kotlin.math.PI import kotlin.math.cos import kotlin.math.max +import kotlin.math.roundToInt import kotlin.math.sin // The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's @@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: } /** - * One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue. - * [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working - * controller can still drive the console UI (and reach Settings to switch it off). + * One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android + * `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is + * actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a + * DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set, + * makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still + * drive the console UI (and reach Settings to switch it off). */ class GamepadHint( val glyph: Char, @@ -201,11 +217,16 @@ class GamepadHint( // Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc — // for a TV remote, which has no A/B/X/Y. val select: Boolean = false, - // Render as the gamepad Select/View button (a small capsule). + // Render as the pad's physical Select/View/Create/− button (per PadStyle) — the button that + // delivers KEYCODE_BUTTON_SELECT. val viewButton: Boolean = false, ) -/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */ +/** + * Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are + * the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo + * monochrome per the driving pad's [Gamepad.PadStyle] at draw time. + */ object PadGlyph { val A = Color(0xFF6BBE45) val B = Color(0xFFD14B4B) @@ -216,6 +237,87 @@ object PadGlyph { ) } +/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */ +internal val PadButtonFace = Color(0xFF2A2740) + +/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */ +class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color) + +/** + * The focus visuals every console form element shares (settings rows, add-host fields, action + * rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and + * resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border + * of a field actively receiving keyboard input. + */ +@Composable +fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals { + val scale by animateFloatAsState( + targetValue = if (active) 1f else 0.98f, + animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), + label = "consoleScale", + ) + val background by animateColorAsState( + if (active) Color(0x336656F2) else Color(0x14FFFFFF), + tween(160), + label = "consoleBg", + ) + val border by animateColorAsState( + when { + editing -> Color(0xB38678F5) + active -> Color.White.copy(alpha = 0.28f) + else -> Color.White.copy(alpha = 0.06f) + }, + tween(160), + label = "consoleBorder", + ) + return ConsoleFocusVisuals(scale, background, border) +} + +/** + * The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet + * track that tints as it engages while the knob slides across on a spring — the state change reads + * from across the room, and the motion confirms the press. + */ +@Composable +fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { + val travel by animateFloatAsState( + targetValue = if (on) 1f else 0f, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f), + label = "switchKnob", + ) + val track by animateColorAsState( + if (on) Color(0xFF6656F2) else Color(0x26FFFFFF), + tween(200), + label = "switchTrack", + ) + val outline by animateColorAsState( + Color.White.copy(alpha = if (focused) 0.45f else 0.15f), + tween(160), + label = "switchOutline", + ) + val trackW = 44.dp + val trackH = 24.dp + val pad = 3.dp + val knob = trackH - pad * 2 + Box( + modifier + .size(trackW, trackH) + .clip(RoundedCornerShape(50)) + .background(track) + .border(1.dp, outline, RoundedCornerShape(50)), + contentAlignment = Alignment.CenterStart, + ) { + Box( + Modifier + .padding(horizontal = pad) + .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) } + .size(knob) + .clip(CircleShape) + .background(Color.White), + ) + } +} + /** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ @Composable fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { @@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { GamepadButtonGlyph('↩', PadGlyph.B, size) } -/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */ +/** + * A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it. + * Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square, + * Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic + * DualShock colours. + */ @Composable -private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { - Box(Modifier.size(size), contentAlignment = Alignment.Center) { - Box( - Modifier - .size(width = size * 0.74f, height = size * 0.46f) - .clip(RoundedCornerShape(50)) - .border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)), - ) +internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) { + val color = when (glyph) { + 'A' -> Color(0xFF7C9CE8) // cross — light blue + 'B' -> Color(0xFFE0736F) // circle — red + 'X' -> Color(0xFFD48FC7) // square — pink + else -> Color(0xFF5FBFA5) // triangle — green + } + Box( + Modifier.size(size).clip(CircleShape).background(PadButtonFace), + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.size(size * 0.46f)) { + val w = this.size.minDimension + val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round) + when (glyph) { + 'A' -> { // ✕ — the two diagonals + drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round) + drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round) + } + 'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke) + 'X' -> drawRect( + color, + topLeft = Offset(stroke.width / 2f, stroke.width / 2f), + size = Size(w - stroke.width, w - stroke.width), + style = stroke, + ) + else -> { // △ + val p = Path().apply { + moveTo(w / 2f, stroke.width / 2f) + lineTo(w - stroke.width / 2f, w - stroke.width / 2f) + lineTo(stroke.width / 2f, w - stroke.width / 2f) + close() + } + drawPath(p, color, style = stroke) + } + } + } + } +} + +/** + * The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens + * Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two + * overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo − (minus). The generic + * fallback wears the capsule too (the near-universal select shape). + */ +@Composable +internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) { + Box( + Modifier.size(size).clip(CircleShape).background(PadButtonFace), + contentAlignment = Alignment.Center, + ) { + when (style) { + Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) { + // The View icon: two overlapping outlined windows; the front one is filled with the + // button face so it visibly occludes the back one. + val corner = RoundedCornerShape(2.dp) + Box( + Modifier.size(size * 0.32f).align(Alignment.TopEnd) + .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + ) + Box( + Modifier.size(size * 0.32f).align(Alignment.BottomStart) + .clip(corner).background(PadButtonFace) + .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + ) + } + Gamepad.PadStyle.NINTENDO -> Text( + "−", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.62f).sp, + textAlign = TextAlign.Center, + ) + else -> Box( + Modifier + .size(width = size * 0.58f, height = size * 0.30f) + .clip(RoundedCornerShape(50)) + .border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)), + ) + } } } @@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, hazeState: HazeState? = null) { // On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses: // A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the - // home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests). - val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + // home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters / + // PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI. + // Defaults to the generic gamepad look off an Activity (preview/tests). + val activity = LocalContext.current as? MainActivity + val padIsGamepad = activity?.lastPadIsGamepad ?: true + val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC val shape = RoundedCornerShape(50) // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent // scrim below) + a light tint; otherwise fall back to a solid frosted fill. @@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze } Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) { when { - h.viewButton -> ViewButtonGlyph() + h.viewButton -> SelectButtonGlyph(padStyle) h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph() !padIsGamepad && h.glyph == 'B' -> BackGlyph() + padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" -> + PsFaceGlyph(h.glyph) + padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" -> + GamepadButtonGlyph(h.glyph, PadButtonFace) else -> GamepadButtonGlyph(h.glyph, h.color) } Spacer(Modifier.width(6.dp)) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 0f87d2c3..5d11b84f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -2,7 +2,12 @@ package io.unom.punktfunk import android.os.Build import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -90,8 +98,11 @@ fun GamepadDialog( }, onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() }, ) - // Cap the card to most of the screen and let the BODY scroll — in a short landscape window the - // title + body + buttons would otherwise overflow and compress/clip the bottom button. + // Cap the card to most of the screen and let body + BUTTONS scroll together — in a short + // landscape window a 5-action stack (host options) exceeds the card even with an empty body, and + // a pinned actions column can only compress/clip its last button. Only the title stays pinned; + // the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows + // the current action even when the stack scrolls. val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp Box( Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), @@ -109,43 +120,66 @@ fun GamepadDialog( verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) - // The body scrolls; the title above and the buttons below stay pinned + always visible. Column( Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp), ) { body() - } - Spacer(Modifier.size(4.dp)) - actions.forEachIndexed { i, a -> - DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick) + Spacer(Modifier.size(4.dp)) + actions.forEachIndexed { i, a -> + DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick) + } } } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale") + val scale by animateFloatAsState( + if (focused) 1.02f else 1f, + spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), + label = "btnScale", + ) + // The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button + // that's scrolled out of a short window, pull it into view (no-op when already visible). + val intoView = remember { BringIntoViewRequester() } + LaunchedEffect(focused) { if (focused) intoView.bringIntoView() } val shape = RoundedCornerShape(14.dp) - val bg = when { - focused -> Color(0xFF6656F2) - primary -> Color(0x336656F2) - else -> Color(0x14FFFFFF) - } - val fg = when { - !enabled -> Color.White.copy(alpha = 0.35f) - focused -> Color.White - primary -> Color(0xFF8678F5) - else -> Color.White.copy(alpha = 0.85f) - } + // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping. + val bg by animateColorAsState( + when { + focused -> Color(0xFF6656F2) + primary -> Color(0x336656F2) + else -> Color(0x14FFFFFF) + }, + tween(160), + label = "btnBg", + ) + val fg by animateColorAsState( + when { + !enabled -> Color.White.copy(alpha = 0.35f) + focused -> Color.White + primary -> Color(0xFF8678F5) + else -> Color.White.copy(alpha = 0.85f) + }, + tween(160), + label = "btnFg", + ) + val borderColor by animateColorAsState( + Color.White.copy(alpha = if (focused) 0.3f else 0.08f), + tween(160), + label = "btnBorder", + ) Box( modifier = Modifier .fillMaxWidth() + .bringIntoViewRequester(intoView) .graphicsLayer { scaleX = scale; scaleY = scale } .clip(shape) .background(bg) - .border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape) + .border(1.dp, borderColor, shape) .clickable( enabled = enabled, interactionSource = remember { MutableInteractionSource() }, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 8ef08b12..85a548fa 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -2,7 +2,19 @@ package io.unom.punktfunk import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -57,6 +69,7 @@ private class GpRow( val detail: String, val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed val activate: () -> Unit, // A → cycle forward (wrapping) / flip + val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text) ) @Composable @@ -72,6 +85,9 @@ fun GamepadSettingsScreen( val rows = buildSettingsRows(s, ::update) var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex + // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the + // value text slides in its AnimatedContent, so the motion matches the button press. + var adjustDir by remember { mutableIntStateOf(1) } val listState = rememberLazyListState() val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -83,11 +99,11 @@ fun GamepadSettingsScreen( when (dir) { NavDir.UP -> if (focus > 0) focus-- NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1) - NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1) + NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) } + NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) } } }, - onActivate = { rows.getOrNull(focus)?.activate() }, + onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() }, ) // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. @@ -121,8 +137,8 @@ fun GamepadSettingsScreen( ConsoleHeader("Settings", horizontalInset = false) } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> - SettingRowView(row, focused = index == focus, onClick = { - if (focus == index) row.activate() else focus = index + SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { + if (focus == index) { adjustDir = 1; row.activate() } else focus = index }) } } @@ -150,9 +166,17 @@ fun GamepadSettingsScreen( } @Composable -private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale") +private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) { + val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) + // The chevrons keep their layout slot and only fade, so the value never jumps sideways when + // focus arrives; the value colour cross-fades with them. + val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons") + val valueColor by animateColorAsState( + Color.White.copy(alpha = if (focused) 1f else 0.6f), + tween(160), + label = "valueColor", + ) Column { if (row.header != null) { Text( @@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale } + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } .clip(shape) - .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { maxLines = 1, ) Spacer(Modifier.weight(1f)) - if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f)) - Text( - row.value, - style = MaterialTheme.typography.bodyMedium, - color = if (focused) Color.White else Color.White.copy(alpha = 0.6f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f)) + if (row.toggled != null) { + // A toggle is a switch, not text — the sliding knob + tinting track IS the value. + ConsoleSwitch(on = row.toggled, focused = focused) + } else { + Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + // The value slides in the direction it was stepped and its width animates, so + // cycling a choice reads as motion through a list rather than a text swap. + AnimatedContent( + targetState = row.value, + transitionSpec = { + val dir = adjustDir + (slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith + (slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using + SizeTransform(clip = false) + }, + label = "value", + ) { value -> + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + } } // The focused row carries its own one-line description — no dedicated (space-eating) - // detail strip. It appears right where you're looking, and the row grows to fit. - if (focused && row.detail.isNotBlank()) { + // detail strip. It unfolds right where you're looking, and the row grows to fit. + AnimatedVisibility( + visible = focused && row.detail.isNotBlank(), + enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)), + exit = fadeOut(tween(90)) + shrinkVertically(tween(150)), + ) { Text( row.detail, style = MaterialTheme.typography.bodySmall, @@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List val target = delta > 0; if (value != target) { write(target); true } else false }, activate = { write(!value) }, + toggled = value, ) return listOf( @@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List Unit): List PadStyle.PLAYSTATION + VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX + VID_NINTENDO -> PadStyle.NINTENDO + else -> PadStyle.GENERIC + } + /** True when [dev]'s source classes include gamepad or joystick. */ fun isPad(dev: InputDevice?): Boolean { val s = dev?.sources ?: return false From b271d0c8160a1f65c04d360acda74b8da89e7430 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 07:34:56 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix(android):=20hold=20BOTH=20Wi-Fi=20locks?= =?UTF-8?q?=20while=20streaming=20=E2=80=94=20HIGH=5FPERF=20alone=20is=20a?= =?UTF-8?q?=20no-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( From 08ab2b6beeb7faf039cf7d46811e06cac5ed8fcc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 08:07:42 +0200 Subject: [PATCH 7/8] =?UTF-8?q?fix(android):=20declare=20WAKE=5FLOCK=20?= =?UTF-8?q?=E2=80=94=20the=20stream's=20Wi-Fi=20locks=20never=20actually?= =?UTF-8?q?=20engaged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- clients/android/app/src/main/AndroidManifest.xml | 6 ++++++ .../main/kotlin/io/unom/punktfunk/StreamScreen.kt | 13 ++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml index 932c3d6c..1bc78c58 100644 --- a/clients/android/app/src/main/AndroidManifest.xml +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,12 @@ reception needs it (also an OEM Wi-Fi power-save hedge). --> + + 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 8dc852f3..fd91c1c8 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 @@ -6,6 +6,7 @@ import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.net.wifi.WifiManager import android.os.Build +import android.util.Log import android.view.SurfaceHolder import android.view.SurfaceView 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 // 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. + // acquire() ENFORCES the WAKE_LOCK permission (manifest) — and a failed acquire MUST be loud: + // 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 wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return@remember emptyList() @@ -144,7 +147,11 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { DisposableEffect(handle) { 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) — // 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. From 17685ff73b6fcb05afd8b1780276c9b3394c0b6a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 08:07:42 +0200 Subject: [PATCH 8/8] build(android): debug APKs ship release-profile rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo's debug profile is not "slower" for this library — it is unusable, and it invalidated every on-device performance test to date: RustCrypto's AES-GCM compiles to generic-array iterator closures with per-byte precondition checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf, 62k samples): ~800 µs of user CPU per 1.4 KB packet — the receive pump pinned above a full core yet only draining ~1,400 pkt/s of a 1,775 pkt/s (20 Mbps) stream, 2.3 MB standing in the kernel socket buffer, the latency-bound flush firing every 2 s forever. With release rust in the same debug APK: pump at ~12 % of a core, socket queue zero, no flushes, 2800x1260@120 streaming clean. preDebugBuild now depends on cargoNdkRelease; `-PrustDebug` opts back into a debug-profile native build for sessions that actually step through Rust. Kotlin debuggability is unchanged. Co-Authored-By: Claude Fable 5 --- clients/android/kit/build.gradle.kts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/clients/android/kit/build.gradle.kts b/clients/android/kit/build.gradle.kts index 61d06187..4220df88 100644 --- a/clients/android/kit/build.gradle.kts +++ b/clients/android/kit/build.gradle.kts @@ -110,8 +110,18 @@ afterEvaluate { // screenshot unit tests render Compose on the JVM and never load libpunktfunk_android.so), so // CI/local screenshot runs don't need the Rust toolchain or NDK. The native build stays wired // for every normal APK/AAR build. + // + // DEBUG APKs SHIP RELEASE RUST. Cargo's debug profile is not "a bit slower" for this library — + // it is unusable: the AES-GCM data-plane decrypt runs through generic-array iterator closures + // with per-byte UB checks instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf): + // ~800 µs of user CPU per 1.4 KB packet, the receive pump pinned over a full core yet unable to + // drain a 20 Mbps stream — every debug-APK on-device test was silently benchmarking unoptimized + // crypto, not the streaming pipeline. Kotlin debuggability is untouched (the APK is still a + // debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native + // build for the rare session that actually steps through Rust. if (!project.hasProperty("skipRustBuild")) { - tasks.named("preDebugBuild").configure { dependsOn(cargoNdkDebug) } + val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease + tasks.named("preDebugBuild").configure { dependsOn(debugRust) } tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) } } }