Files
punktfunk/clients/android
enricobuehler d73bdcdcc1 chore(deps): jni 0.21 → 0.22 for the Android JNI bridge
Version currency, but a real API migration rather than a version-number edit — and the
payoff is bigger than "one crate is newer", because jni 0.22 was ALREADY in this .so:
`rustls-platform-verifier` (via quinn-proto, for Android cert verification) depends on it,
so pinning 0.21 here compiled two complete jni copies into one library. Matching the
version collapses them.

Eliminated, measured as the delta in THIRD-PARTY-NOTICES.txt (582 → 571 crates, nothing
added): jni 0.21.1, its `cesu8`, and — because jni 0.21 was the SOLE consumer of
windows-sys 0.45.0, the oldest crate in the tree — that whole windows-rs 0.42 generation:
windows-sys 0.45.0, windows-targets 0.42.2 and its seven per-arch import libraries. Eleven
crates, carried for a `cfg(windows)` dependency of an Android-only bridge.

NOT eliminated, contrary to what the sweep expected — recorded in the manifest so the next
person does not re-derive it. thiserror 1.0 and the jni-sys 0.3/0.4 split both survive,
because jni was never their only source:

  thiserror 1.0.69  ← vendor/ndk 0.9.0 (+ asn1-rs/x509-parser, host side)
  jni-sys 0.3.1     ← vendor/ndk 0.9.0 AND crates.io ndk-sys 0.6

jni's share of both is gone; the remainder is the ndk stack. jni-sys 0.3.1 is itself a
facade crate over 0.4.1, so the split cannot close until ndk and ndk-sys move, and `ndk` is
vendored for a one-line visibility patch — changing its dependency versions would mean
rewriting the vendored snapshot instead of reading it as a diff against upstream. Left
alone deliberately.

The migration itself, across 66 native methods in 10 files:

  * `JNIEnv` split into `EnvUnowned` (the FFI-safe native-method argument) and `Env` (where
    the JNI calls live). The 41 methods that never touched the environment are a type
    rename; the 22 that do now acquire an `Env` inside `EnvUnowned::with_env` and map the
    outcome with an `ErrorPolicy`.
  * `LogErrorAndDefault` everywhere, chosen to PRESERVE behaviour: the old code swallowed
    JNI errors and returned a default, and this logs and returns the same default. The
    throwing policy would have been a behaviour change (new Java exceptions out of methods
    that previously failed quietly), which is not what a currency bump should do.
  * `with_env` also catches panics, which is exactly what the crate's own `jni_guard` did,
    so the guard is folded into it where the two would have nested. It stays on the methods
    that take no `Env`. ONE exception, marked at the call site: `nativeNextHidout` returns
    -1 as its failure sentinel, and every error policy resolves to `T::default()` — which
    for `jint` is 0, a *valid* byte count. That method keeps `jni_guard(-1, …)` outside and
    uses `with_env_no_catch` inside so the sentinel survives the panic path unchanged.
  * `jboolean` is `bool` in jni-sys 0.4, not `u8` — a type change, not a rename, and the
    reason for most of the mechanical diff (`down != 0` → `down`, `return 0` → `false`).
  * `Env::get_string` is deprecated in favour of `JString::try_to_string`, and CI runs
    clippy with `-D warnings`, so the call sites moved rather than being left to warn.
    Likewise `set_/get_*_array_region` → `JPrimitiveArray::set_region`/`get_region`.
  * `Env::get_native_interface()` is now `Env::get_raw()` — the raw pointer handed to
    `ndk::NativeWindow::from_surface`. The `as *mut _` cast next to it was already commented
    as bridging jni-sys skew between `jni` and `ndk`; that skew is now real (0.4 vs 0.3)
    rather than hypothetical, so the comment says so.
  * Return types moved from raw `jni::sys::jstring`/`jdoubleArray`/`jintArray` to
    `JString`/`JDoubleArray`/`JIntArray`, because `resolve()` requires `T: Default` and raw
    pointers have no `Default`. All three are `#[repr(transparent)]` over the same
    `jobject`, so the exported ABI is unchanged and `Default` IS the null reference the old
    code returned explicitly.

Kotlin and Gradle needed NO changes, checked rather than assumed: every affected
`external fun` in NativeBridge.kt already declares `Boolean` / `String?` / `IntArray?` /
`DoubleArray?`, which is what these signatures still present to the JVM, and the Gradle
side only shells out to cargo-ndk without naming a jni version.

Verified on NDK 30.0.14904198, both shipping ABIs, using the environment
clients/android/kit/build.gradle.kts hands cargo-ndk:

  cargo clippy -p punktfunk-client-android --all-targets -- -D warnings   → ok (host)
  cargo test   -p punktfunk-client-android                                → 20 passed
  cargo ndk -t arm64-v8a   --platform 28 clippy … -- -D warnings          → ok
  cargo ndk -t armeabi-v7a --platform 28 clippy … -- -D warnings          → ok
  cargo ndk -t arm64-v8a -t armeabi-v7a --platform 28 build               → both .so LINKED
  scripts/ci/check-android-jni-imports.sh … 28    → 2 ABI(s) clean at the API-28 floor

That last one matters for this change specifically: a cdylib links with dangling undefined
symbols, so the floor check is the only thing that would catch jni 0.22 hard-importing an
NDK entry point above minSdk 28 — the shape of the 0.9.0 `System.loadLibrary` regression.
It is clean.

`cargo tree -i ring` stays empty on host and on aarch64-linux-android.
2026-08-13 13:58:08 +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.