Reverts half of1eab4b66and closes G10's open frame question, both settled by the same measurement.1eab4b66made two corrections to the Apple phone-gyro mirror. The negation was right and stays: Apple reports the gravity VECTOR, pointing down, while an accelerometer measures proper acceleration, pointing up at rest, and the wire carries the latter. The frame change was wrong, and this removes it. The mistake was a name collision. Two different frames are both called "the controller frame". GCMotion reports a CONTROLLER in (Right, Forward, Up) — measured on a real DualSense — which is not the wire's frame, which is why `GamepadCapture.forwardMotion` converts. The mirror's orientation remap resolves THIS DEVICE into the frame its header describes, x right, y up, z out of the screen. For the pose that mirror exists to serve — a phone clipped upright with the screen facing the player — "out of the screen" points at the player, so that frame is (Right, Up, Backward), which IS the wire's. It was already correct. Applying the controller path's conversion on top rotated it out of true: a phone sitting still would have reported gravity as −1 g on the roll axis rather than +1 g up, i.e. claimed to be lying on its edge. Reasoning by analogy is what produced it — "the mirror says controller frame, the capture path says controller frame, so the same fix applies". Both files say it; they mean different things. What caught it was measuring the Android twin, which does the same thing straight through. On glass: a DualSense on Bluetooth to a phone, streaming to a Linux host, reads +1 g on the up axis end to end. Had the Apple mirror needed a conversion, the Android one would have needed the same one and would have been visibly wrong. It is not. The same run settles G10's frame, which shipped straight-through and explicitly unverified because nobody had put a Bluetooth pad in front of the platform sensor framework. Now somebody has. `PadSensors`' own first-sample log read `accel 0, 10000, 0` — exactly 1 g on slot 1 — and at the far end hid-playstation published gravity as +0.991 g on ABS_Y, with every rotation driving its correctly-named axis and the signs agreeing with gravity's independent witness on 95 of 100 rotating samples. Android hands a controller's sensors over in the pad's own frame, as documented. No remap, and the comment now says measured instead of assumed. Worth recording why the earlier suspicion was wrong, since it is the same trap in the other direction: Android's DEVICE sensor frame really does put +z out of the screen, so a flat phone puts gravity on z — but a CONTROLLER's sensors are reported in the controller's frame, not the phone's. One platform, two conventions, chosen by what the sensor is attached to. Gate: Apple macOS `swift build` + full suite (215 tests, 5 skipped, 0 failures) and the iOS-triple typecheck, which is what actually compiles `DeviceGyro.swift`; Android `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`. Green. Still owed: `DeviceGyroRemap`'s four orientation matrices remain derived — this run used a controller's own sensors, not the mirror, so it says nothing about them. They need a gyro-less pad on wire index 0 and a phone turned through all four orientations.
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