Files
punktfunk/clients/android
enricobuehler 3c25ff1a80 feat(client/android): per-AU attribution for latency excursions
The e2e spikes WP2 surfaced (79-106 ms against a 9-16 ms p50) could not be
diagnosed from what we log. Window maxima cannot say WHERE a 90 ms frame spent
its time, because the per-stage maxima in a window are generally different
frames. Attribution has to be captured per AU, for the offending AU.

`debug.punktfunk.spike_ms` arms a watch: every AU whose capture->reassembled
latency crosses the threshold is logged to `pf.spike` with the host's own
pipeline time for that same AU (from the 0xCF timing plane), the remainder on
the wire, the AU's size, and the gap since the previous AU was reassembled.
That last field is what separates "this frame was slow" from "the stream
stalled and then burst" — indistinguishable in any percentile. Off by default,
so it costs nothing until someone asks.

Also moves the 0xCF drain OUT of the HUD gate. It carries the host's per-AU
pipeline time and the phase-lock ACK, neither of which had any business being
invisible with the HUD down — the same HUD-gating trap this client has now hit
three times.

What it found immediately, on .173 over Wi-Fi:

  idx=2383 hostnetMs=72.8 hostMs=4.6 netMs=68.2 gapMs=69.5
  idx=2384 hostnetMs=66.4 hostMs=4.9 netMs=61.6 gapMs=1.8
  idx=2385 hostnetMs=62.2 hostMs=4.9 netMs=57.3 gapMs=3.8
  ...
  idx=2420 hostnetMs=69.2 hostMs=4.3 netMs=65.0 gapMs=69.3

A ~69 ms window where nothing is delivered, then a burst that drains the
backlog, recurring every ~305 ms. Across 112 attributed frames the host
pipeline is 2.0-8.0 ms (mean 3.9) and never spikes: the excursion is entirely
wire time. Our Wi-Fi low-latency lock is confirmed active throughout
(`dumpsys wifi`: both locks held, uid in the low-latency watchlist,
operation mode 4), so this is NOT the power-save trap StreamScreen already
defends against — that defence is working and is not sufficient.

Gates: cargo ndk check + clippy arm64 clean (the 5 warnings are pre-existing
and in other files); fmt clean.
2026-08-06 00:15:11 +02:00
..

punktfunk — Android client (phone & TV)

The native Android app for streaming a punktfunk host to your phone, tablet, or Android TV. A Compose app that finds hosts on your network, pairs with a PIN, and streams at the display's own resolution — with hardware HEVC decode, HDR10, and controller support, built for both touch and the couch (D-pad / gamepad focus navigation).

Features

  • Hardware decode — NDK AMediaCodec HEVC → SurfaceView, including HDR10 (Main10 / BT.2020 PQ), with low-latency tuning and a live stats HUD.
  • Audio both ways — Opus + AAudio playback with a jitter ring, plus mic uplink to the host.
  • Controller support — buttons + axes with rumble and HID feedback (lightbar / adaptive triggers); D-pad / gamepad focus navigation for TV and phone.
  • Find hosts automatically — native mDNS discovery; first connect does a one-time SPAKE2 PIN pairing (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
  • Compose UI — Connect / Settings / Stream screens with Material You theming.

Built for arm64-v8a + armeabi-v7a + x86_64 — the 32-bit armeabi-v7a slice is what keeps the app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".

Get it

Published to Google Play (Internal Testing) — join the beta via the Discord. Per-device setup and pairing: docs.punktfunk.unom.io/docs/install-client.

How it's built — Rust-heavy

Kotlin can't import the cbindgen C header the way Swift can, so a native bridge is unavoidable. We write it in Rust and link punktfunk-core directly — so the Android client reuses the Linux client's orchestration (audio jitter ring, VK keymap inverse, latency/skew math, capture state machine, trust logic) instead of re-porting it into Kotlin.

Side Owns
Rust (native/libpunktfunk_android.so) the JNI seam, NativeClient (QUIC control + UDP data plane), AnnexB → AMediaCodec decode (incl. HDR10), Opus + AAudio audio + mic, controller feedback, latency math, trust/pairing, mdns-sd discovery
Kotlin (app/, kit/) Compose UI, SurfaceView lifecycle, input capture, the Wi-Fi MulticastLock + permission UX, Keystore identity

The single seam is io.unom.punktfunk.kit.NativeBridgeJava_io_unom_punktfunk_kit_NativeBridge_*.

native/           Rust cdylib (workspace member) — links punktfunk-core directly
  src/lib.rs        crate doc · JNI_OnLoad · version probes
  src/session/      session lifecycle: connect/pair + trust, plane start/stop, input shims
  src/decode.rs     AnnexB → AMediaCodec HEVC hardware decode → SurfaceView (incl. HDR10)
  src/audio.rs · src/mic.rs   Opus + AAudio playback / mic uplink
  src/feedback.rs · src/stats.rs   rumble + HID feedback; live video stats
  src/discovery.rs  native mdns-sd browse of the host's _punktfunk._udp advert
app/              :app — Compose UI: Connect / Settings / Stream (phone + TV)
kit/              :kit — NativeBridge · native mDNS discovery · Gamepad · Keymap · Keystore identity

Build & run

Prerequisites: Android SDK + NDK r30 (30.0.14904198), platforms;android-37.0, build-tools;37.0.0, cmake;3.22.1 (builds libopus); JDK 21 (AGP 9.2 runs on JDK 1721, not a newer default); Rust with rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android and cargo install cargo-ndk. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM 2026.05.01 · compileSdk 37 · minSdk 28).

Android Studio: open clients/android — it uses its bundled JBR 21, and the cargoNdk* task builds the .so as part of the normal build.

CLI (point Gradle at JDK 21 if your machine default is newer):

export JAVA_HOME="$(/usr/libexec/java_home -v 21)"   # or your Temurin 21 path
cd clients/android
./gradlew :app:assembleDebug     # cargo-ndk cross-compiles libpunktfunk_android.so first
./gradlew :app:installDebug      # onto a running emulator/device
# emulators from env setup:  emulator -avd pf_phone   |   emulator -avd pf_tv

The debug APK lands in app/build/outputs/apk/debug/. Launch it, pick a host, pair, and stream.