Supersedes the parse gate in26b0819f. The off-thread read, the claim token, the teardown ordering and its bounded wait all stand — only what happens in the gap changes.26b0819fheld every report back until the calibration read came home, so a pad that stalled on EP0 could feel dead for up to the link's 250 ms timeout: no buttons, no sticks, nothing. Reports are now forwarded immediately and their motion scaled by the nominal calibration until the real one lands. That gap is exactly the behaviour that shipped beforef6de620f— acceleration ~18% short, gyro unscaled — for about a millisecond. Nobody can feel that. A controller that ignores a button press for a quarter of a second is not in the same category, and it is the only one of the two a user would ever report. It is also the safer of the two conservatisms available here. The rejected third option, forwarding motion as zeroes until the real numbers arrive, would have the host read a still pad as being in free fall — a lie about the physical world rather than an imprecision about it. The nominal constants are merely a slightly wrong scale. The token is more load-bearing under this, not less. With a gate, an unpublished calibration meant "parse nothing"; now it means "scale nominally", so begin() clearing the previous pad's value is the whole reason a re-claim falls back to the nominal constants instead of silently inheriting factory numbers belonging to a different unit — which are, in general, further off than nominal. The fallback therefore lives in the hand-off itself (MotionCalHandoff.effective) rather than as an elvis at the call site: restoring the gate now means changing the type's API, not deleting three characters in onReport. The tests moved with the contract. They assert the nominal calibration is what is in effect during the gap, rather than merely that the slot is empty — an empty slot is now compatible with either behaviour, so asserting on it would have let a regression pass. Added the case the change exists for: the same raw report, parsed either side of publication, forwards identical buttons and sticks while its gyro and acceleration convert differently. Mutation-checked three ways — dropping the nominal fallback fails all five cases, dropping begin's clear fails the inheritance case, dropping the token check fails three. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` and `:app:compileDebugKotlin` green on a forced clean rerun, 62 cases across the module, 0 failed, with the five hand-off cases read back out of the JUnit XML. The on-glass re-verificationf6de620fowes is still owed and unchanged.
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
AMediaCodecHEVC →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.NativeBridge ⇄ Java_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 17–21, 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.
Related
- Documentation — quick start, pairing, troubleshooting
- Project README — the host, the other clients, and how it all fits together