Discovery: replace the flaky per-OEM NsdManager with the same mdns-sd browse
the Linux/Windows clients use, in the Rust core over JNI and polled by Kotlin
(discovery.rs + nativeDiscovery{Start,Poll,Stop}); Kotlin keeps only the Wi-Fi
MulticastLock + permission UX. IPv4-only (the core can't dial a bare/scoped v6
literal); daemon + fold-thread cleanup on every failure path; field
sanitization so a rogue advert can't corrupt the picker snapshot. Discovery
now starts regardless of NEARBY_WIFI_DEVICES (raw multicast only needs the
MulticastLock) — a denial no longer kills it forever. ParseTxtTest replaced by
ParseRecordTest.
Hosts: hide already-saved hosts from the "Discovered" section (match by
fingerprint, else address:port — mirrors the Apple client); add an optional
Name field to the Add-host sheet and a Rename action on saved cards.
Input: touch -> absolute mouse "direct pointing" like the Apple client — the
host cursor follows the finger (new nativeSendPointerAbs -> MouseMoveAbs). Tap
= left click, two-finger tap = right click, two-finger drag = scroll,
tap-then-drag = left-drag, three-finger tap = HUD toggle.
Settings: revert the dropdowns to the stock ExposedDropdownMenuBox look (a
controller-focus UI will come separately); even out the Add-host field gaps.
Docs updated (CLAUDE.md, client READMEs, docs-site status).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a
physical Xbox One or PS4 pad on the client gets a near-native matching virtual
pad on the host, auto-resolved from the controller type.
Protocol/core:
- GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/
from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants
(compile-time guard ties them to the enum). Single-byte wire form is
unchanged, so it's forward-compatible (older peers degrade to Auto).
Host (Linux):
- New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation:
lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers /
player LEDs / mute. Reuses the DualSense pure state + button mapping; only the
report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake
(0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad
resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane,
lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane.
- Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S
USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise.
- PadBackend dispatch + resolver handle both; off Linux the UHID pads and
One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred.
Clients (auto-resolve physical pad -> virtual type, plus manual settings):
- Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE ->
Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture +
lightbar already type-agnostic. Linux settings combo + label updated.
- Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4
touchpad capture, settings picker entries.
- Android (Kotlin): InputDevice VID/PID auto-detect (matching the other
clients) + settings entries.
- probe: --gamepad help/aliases.
Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in
catch_unwind so a panic degrades to a logged no-op instead of aborting the app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continues docs/hdr-pipeline-plan.md. Steps 0/1 + Step 2 (Windows/Android) already
landed in ffae2a3; this is Step 2 (Apple) + Step 3 (all clients). Client-only — no
core/host/ABI change (the 0xCE/next_hdr_meta/color_info surfaces shipped in Step 0).
Step 2 — clients APPLY the host's HDR metadata (each remaps from the wire form: ST.2086
G,B,R order, mastering luminance in 0.0001 cd/m2):
- Apple: connect via punktfunk_connect_ex5 (resurrects the previously-dead HDR pipeline);
nextHdrMeta/colorInfo wrappers + HdrMeta SEI-blob builders; the pump drains nextHdrMeta
-> VideoDecoder.setHdrMeta -> CVBufferSetAttachment of MasteringDisplayColorVolume (24B
BE) + ContentLightLevelInfo (4B BE) on each HDR pixel buffer (correct for the
itur_2100_PQ layer; CAEDRMetadata avoided as ambiguous there).
Step 3 — capability-gate: advertise HDR caps ONLY when the display can present it, so an
SDR display gets a proper BT.709 stream instead of PQ it would mis-tone-map; an HDR
display self-tone-maps from the Step-1/2 mastering metadata.
- Windows: present::display_supports_hdr() (DXGI any IDXGIOutput6 colour space == G2084),
ANDed with the user HDR setting in session.rs; logs the SDR drop.
- Apple: NSScreen.maximumExtendedDynamicRangeColorComponentValue>1 (macOS) /
UIScreen.main.potentialEDRHeadroom>1 (iOS) in SessionModel.
- Android: Settings.displaySupportsHdr (Display.getHdrCapabilities HDR10/HDR10+) passed
through a new hdr_enabled jboolean on nativeConnect; session.rs gates the caps.
Validation: Android native (incl. the jboolean gate) builds + clippy clean via cargo-ndk;
fmt clean. Windows (MSVC), Apple (Swift) and the Kotlin side are CI/on-glass validated —
not compilable on the Linux dev box. Deferred to the RTX box: mid-session Reconfigure
SDR-downgrade on monitor move, and confirming the host emits SDR for an SDR client off an
HDR desktop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ExposedDropdownMenuBox anchors on a read-only OutlinedTextField, and a text field
captures D-pad focus -- directional keys never escape it, so on a TV/controller you
got stuck on the first select. Replace SettingDropdown with a clickable Surface +
DropdownMenu (no text field): D-pad moves between settings, A opens the menu, A
selects an item. Adds a primary-colour focus border so the focused setting reads
across a room.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a controller drive the Compose UI when not streaming, so the menus work on a TV
remote AND on a controller paired to a phone:
- MainActivity maps gamepad face buttons to the keys Compose's focus system
understands (A -> DPAD_CENTER to activate, B -> BACK); D-pad *keys* already move
focus and pass through untouched.
- For controllers whose D-pad reports as HAT axes (or to navigate with the left
stick), dispatchGenericMotionEvent converts AXIS_HAT_X/Y / AXIS_X/Y into discrete
D-pad key events, edge-detected so a held direction moves focus exactly once.
- HostCard draws a clear primary-colour focus border (the default state layer is too
subtle across a room on TV).
All gated on "not streaming" -- during a stream the controller still forwards to the
host unchanged. Compile-verified (./gradlew :app:assembleDebug); the focus behaviour
itself needs on-device validation (no KVM here for a TV emulator).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Settings: flat list -> Display / Host / Audio / Overlay sections in outlined
cards (SettingsGroup + ToggleRow helpers) with section headers.
- ConnectScreen: connection errors now show in a filled errorContainer banner
(was plain red text lost in the layout), and a "Searching the local network..."
spinner appears while discovery is active but nothing's turned up yet.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Crash: DisposableEffect.onDispose called nativeClose(handle) (Box::from_raw frees
the SessionHandle) while the SurfaceView's surfaceDestroyed independently called
nativeStopVideo/Audio/Mic on the same handle -- whichever ran after the close
dereferenced freed memory (SIGSEGV: the consistent back-navigation crash). Add a
one-shot `closed` guard: onDispose marks it before freeing; surfaceDestroyed skips
the native calls once closed (backgrounding still stops the threads when it wins).
Polish:
- Branded Material You theme (Theme.kt): dynamic colour on Android 12+, punktfunk
brand violets as the pre-12 fallback, replacing the generic darkColorScheme().
- ConnectScreen: "Connecting..." was rendered in error-red with no spinner; now a
neutral spinner while connecting, red reserved for actual errors.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL (both ABIs + the
Compose changes), debug APK assembles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stats HUD (mirrors the Apple client): the decode thread accumulates FPS, receive
throughput, and capture->client latency (p50/p95, skew-corrected) in Rust
(clients/android/native/src/stats.rs); nativeVideoStats drains a snapshot ~1 Hz
over JNI as a DoubleArray. StreamScreen renders a Compose overlay
(W*H@Hz / fps / Mb/s / latency, + dropped-under-loss), toggled by a Settings
switch (persisted, default on) or a 3-finger tap.
Performance (decode.rs):
- ANativeWindow_setFrameRate(refresh_hz): align display vsync to the stream rate
(no 60-in-120 judder); safe since minSdk 31 >= API 30.
- Raise the decode thread toward URGENT_DISPLAY (best-effort setpriority) so
background work can't preempt it under load.
- Codec low-latency hints KEY_PRIORITY=0 (realtime) + KEY_OPERATING_RATE.
Verified host-side: cargo build/clippy/fmt --workspace (the ungated stats + JNI
accessor). The android-gated decode.rs (NDK) and the Kotlin build only in CI
(android.yml: gradle + cargo-ndk) -- APIs verified against crate sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NsdManager service discovery needs NEARBY_WIFI_DEVICES on Android 13+. The app DECLARED it but
never REQUESTED it, so on a real device the permission stayed denied and discoverServices silently
found nothing — no prompt, no hosts. (It only worked on the emulator because the permission was
granted via `adb pm grant`.) Request it (mirroring the mic RECORD_AUDIO flow) when the connect
screen appears, and start/restart discovery once granted; on API < 33 discovery starts immediately
(the permission doesn't apply there). The advertised hosts the Apple clients already see will then
appear here too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the test phone's launcher the standard (colored) adaptive foreground rendered noticeably larger
than the themed (monochrome) layer — identical geometry, but the launcher insets/scales the two
differently — so the colored circles overflowed the circle mask. Shrink only the foreground group
(scale 0.105 → 0.073, re-centred) to match the correctly-sized monochrome; the monochrome layer is
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the placeholder system icon with the Punktfunk brand mark (two overlapping violet circles,
from the shared logo in clients/apple/.../punktfunk_Logo.icon).
- drawable/ic_launcher_foreground.xml: the violet logo (3 exact paths) scaled + centered into the
108dp adaptive-icon safe zone via a group transform.
- drawable/ic_launcher_monochrome.xml: single-tone silhouette for Android 13+ themed icons
(Material You) — the launcher recolors it to the wallpaper.
- mipmap-anydpi-v26/ic_launcher{,_round}.xml: adaptive-icon (background + foreground + monochrome);
dark-indigo background (@color/ic_launcher_background) so the violet pops.
- Manifest: android:icon=@mipmap/ic_launcher + roundIcon (was @android:drawable/sym_def_app_icon).
minSdk 31 → anydpi-v26 covers every device (no legacy PNG mipmaps needed). Verified on a physical
phone (Android 16): the icon renders centered + circle-masked; the themed-icon layer is wired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Display name capitalized: app_name (launcher label + permission dialogs) and the connect-screen
header are now "Punktfunk". Package/applicationId/service names stay lowercase.
- Settings: removed the redundant "Done" button (the bottom tab bar is the navigation; system Back
still returns to Connect). Dropped the now-unused imports.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polish pass on the connect screen.
- Host cards: ElevatedCard with a colored letter-avatar (Apple-contact style), name + address, a
colored status pill (Paired / PIN pairing / Trust on first use), and an overflow menu with Forget
on saved hosts. Tapping a card connects. Unifies the old saved/discovered rows into one HostCard.
- Manual connect moved behind an "Add host" ExtendedFloatingActionButton that opens a
ModalBottomSheet with the Host/Port form (the current M3 pattern) — declutters the list.
- Empty state when there are no saved/discovered hosts; single scrollable column; removed the
"core ABI v2" footer.
- Status bar: enableEdgeToEdge driven explicitly dark (transparent bars + light icons) so the
status/nav bars blend with our always-dark surface instead of showing a black band (the no-arg
edge-to-edge had picked the system light/dark theme).
Verified live (emulator screenshots): cards render with avatars + status pills + Forget menu; the
FAB opens the bottom-sheet form; the status bar blends with light icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Microphone uplink (client → host's virtual mic, 0xCB) and a cleaner connect screen.
Mic (Rust-heavy, mirrors the audio playback path in reverse):
- crates/punktfunk-android/src/mic.rs: AAudio LowLatency **input** → realtime callback hands
captured f32 to a channel → a worker thread Opus-encodes 20 ms stereo frames (48 kHz, VOIP,
64 kbps) and calls NativeClient::send_mic. MicCapture owns the stream + encode thread (RAII stop).
- session.rs: SessionHandle gains a `mic` slot; nativeStartMic/nativeStopMic JNI (mirror of audio);
stopped in Drop. NativeBridge: the two externs.
- Settings: a `micEnabled` flag + a Microphone toggle in SettingsScreen that requests RECORD_AUDIO
(denied → stays off). StreamScreen starts the mic only if enabled AND the permission is held.
Connect-screen redesign:
- One scrollable Column (was a fixed centered layout that could clip with the new tab bar);
host rows render via forEach (no nested LazyColumn). Colored section labels ("Saved hosts",
"Discovered on the network", "Connect manually"), full-width host cards / fields / Connect button,
a header + subtitle, and a muted footer.
Verified live (emulator pf_phone -> home-worker-2): toggling mic requests RECORD_AUDIO; with it
granted, a session sends mic frames (client "mic: sent=250 … peak=0.439" — real audio) and the host
logs "client datagram stream ended … mic=276". Redesigned screen confirmed via screenshots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the ad-hoc screen switching with a Material3 bottom NavigationBar. Two top-level
destinations — Connect (Home icon) and Settings (gear) — persist across tab switches; the
immersive stream view is shown full-screen, outside the bar. Settings is now a tab, so its
button is dropped from the Connect screen.
- app/build.gradle.kts: + androidx.compose.material:material-icons-core (tab icons).
- MainActivity: Screen sealed interface -> Tab enum; App() wraps the tabs in a Scaffold with a
NavigationBar bottomBar (streamHandle != 0 -> StreamScreen full-screen); ConnectScreen drops
the onOpenSettings param + the Settings button.
Verified live (emulator): the bar renders with Connect/Settings; tapping a tab swaps content and
moves the selected indicator; the bar persists on both tabs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A managed list of known/paired hosts on the connect screen — one-tap reconnect + forget —
and a fix for the discovered-vs-manual trust-key split.
- kit/security: KnownHostStore (replaces the fp-only PinStore) stores KnownHost{address, port,
name, fpHex, paired} keyed by address:port, persisted as JSON in SharedPreferences. So a
discovered and a manually-typed connection to the same host now share ONE trust record (the old
PinStore keyed discovered hosts by the mDNS instance id, manual by host:port — pairing via one
path wasn't seen by the other).
- MainActivity: connect() looks up trust by (address, port); on a successful TOFU or PIN pairing
the host is saved (paired flag set for the PIN path). A "Saved hosts" section lists them (name,
address:port · paired/trusted, fp) with tap-to-reconnect (silent, pinned) and a Forget button.
Verified live (emulator -> home-worker-2): pair -> host appears under "Saved hosts" as paired;
tap -> silent reconnect (new host session, no dialog); Forget -> removed. Trust now shared across
the discovered + manual paths by construction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The connect mode was hardcoded to 720p60 — violating the "native client resolution, no
scaling" invariant. Derive the device's real display mode (landscape, long edge = width) and
add a Settings screen to tune the stream, mirroring the Linux/Apple clients.
- crates/punktfunk-android: nativeConnect gains bitrateKbps + compositorPref + gamepadPref
(CompositorPref/GamepadPref wire bytes via from_u8); these were hardcoded Auto/Auto/0.
- app/Settings.kt: Settings (width/height/hz/bitrate/compositor/gamepad; 0 = native/auto) +
a SharedPreferences store + nativeDisplayMode (Display.mode, landscape-swapped) +
effectiveMode + the UI option tables.
- app/SettingsScreen.kt: dropdowns for resolution / refresh / bitrate / compositor / controller.
- MainActivity: App owns the settings + a Settings screen; ConnectScreen resolves the effective
mode (Native = the display), shows it on the Connect button, and threads the prefs through
nativeConnect.
Mic + codec selection deferred (mic uplink isn't wired yet; the decoder is HEVC-only).
Verified live (emulator pf_phone -> home-worker-2): default -> host mode=2400x1080@60 (the
emulator's native display, was 720p); Settings 1920x1080 + 20 Mbps + DualSense -> host
mode=1920x1080, requested_kbps=20000, gamepad=dualsense (host created a UHID DualSense).
Settings persist across screens; pinned reconnect stays silent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TOFU let anyone who could reach the host click "Trust" and stream, which defeats the point
on a LAN. Make SPAKE2 PIN pairing the default and only way to trust a NEW host; TOFU survives
as an explicit HOST opt-in (for fully trusted networks), advertised over mDNS so clients render
their trust UI from the host's policy rather than offering trust on faith.
Contract:
- Host advertises pair=required (default) or pair=optional. pair=required rejects unpaired
clients at the handshake; pair=optional accepts them (TOFU).
- Clients: a pinned host whose fingerprint matches connects silently; a pinned host whose
fingerprint CHANGED forces re-pairing via PIN (no re-trust shortcut); a NEW host is offered
TOFU only if it advertised pair=optional, otherwise PIN pairing is mandatory; a manually-typed
or unknown-policy host is always PIN.
Host (crates/punktfunk-host/src/main.rs):
- m3-host now REQUIRES pairing by default (was open by default). New --allow-tofu opts into
accepting unpaired clients + advertising pair=optional; pairing is always armed (PIN logged at
startup). serve --native was already secure-by-default (serve --open). The mDNS advert and the
accept loop already mapped require_pairing -> pair=required + reject; only the m3-host CLI
default + help text changed.
Clients honor the advertised policy:
- Android (MainActivity.kt): TOFU only for a discovered pair=optional host; manual/unknown -> PIN;
fp-change -> re-pair only (dropped the "Forget & re-TOFU" shortcut).
- Apple (HostDiscovery/SessionModel/ContentView/HostCards/HostStore): new allowsTofu
(pair==optional, distinct from unknown); connect() gates .awaitingTrust on it; unpinned
non-optional hosts route to the PIN sheet; "Forget Identity" re-pairs rather than re-TOFUs.
- Linux (app.rs/ui_hosts.rs/session.rs): ConnectRequest.pair_required -> pair_optional;
initiate_connect routes pinned/fp-changed/optional/else; manual + --connect unknown -> PIN; a
pinned connect rejected on trust grounds re-pairs.
Docs (CLAUDE.md, README.md, docs-site/content/docs/pairing.md): describe the gated model — PIN is
the default, TOFU an explicit opt-in with an impostor warning.
Verified: host cargo check/clippy/fmt clean; Android built + live (emulator -> home-worker-2):
a manual connect now opens the PIN dialog (no Trust button) and the PIN ceremony streams; Apple
swift build clean; Linux clippy -D warnings + fmt clean on the Linux box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M4 Android stage 1 (trust). The client now presents a persistent self-signed identity on
every connect, pins host certs trust-on-first-use, and runs the SPAKE2 PIN pairing
ceremony — parity with the Apple/Linux clients. The Rust connector already exposed this;
this wires it through the JNI + a Keystore-backed Kotlin store + the connect UI.
- crates/punktfunk-android: nativeGenerateIdentity (mint), nativeConnect gains
certPem/keyPem/pinHex (identity + TOFU/pinned), nativeHostFingerprint, nativePair
(SPAKE2). hex32/parse_hex32 helpers.
- kit/security: IdentityStore (AndroidKeyStore AES-256-GCM-wrapped PEM blob; StrongBox
with TEE fallback; four-state load so a decrypt failure never shadow-mints), PinStore
(host-id -> fp-hex in SharedPreferences). obtainIdentity mints once on genuine first run.
- app: ConnectScreen loads/mints the identity, looks up the stored pin, and gates connect
on a trust decision — TOFU prompt (first connect), fingerprint-changed warning, PIN dialog.
- AndroidManifest: allowBackup=false (Keystore keys don't restore; a restored device
re-mints rather than carrying a dead blob).
Verified live (emulator -> home-worker-2, synthetic m3-host):
- identity: host logs the presented client fingerprint; stable across an app restart.
- TOFU: first-connect prompt -> Trust -> pins the observed host fp -> pinned reconnect
skips the prompt.
- SPAKE2: PIN ceremony -> "pairing complete — client trusted" -> auto-connect under
--require-pairing; wrong PIN / host down -> "Pairing failed".
Known follow-up: trust is keyed by mDNS instance id for discovered hosts but by
"host:port" for manually-typed ones, so pairing via one path isn't recognized by the other.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M4 Android stage 1 (discovery). Kotlin-only — browse _punktfunk._udp and present a
tappable host list above the manual Host/Port fields.
- clients/android/kit: HostDiscovery — NsdManager browse + resolve (registerServiceInfoCallback
on API 34+ for reliable TXT, legacy resolveService on 31-33), MulticastLock while running, and
a pure parseTxt(proto/fp/pair/id). Exposes the live host set via an onChange callback (NSD
callbacks land on the main thread). DiscoveredHost(name, host, port, fingerprint?, pairingRequired).
+ a JVM unit test of parseTxt.
- clients/android/app: ConnectScreen renders discovered hosts (tap -> fill host/port + connect);
discovery scoped to the screen (start on enter, stop on connect/leave). Manifest adds
CHANGE_WIFI_MULTICAST_STATE + ACCESS_WIFI_STATE (NEARBY_WIFI_DEVICES already declared). Trust
stays TOFU (pin=None); fp shown advisory; pairingRequired shown (SPAKE2 PIN wiring is later).
Verified: parseTxt unit test (5/5 green); on the emulator a loopback NsdManager.registerService of
a fake _punktfunk._udp host was discovered + resolved + TXT-parsed and rendered as a card
(name/host:port/TOFU/fp) -- the full browse->resolve->parse->UI path. Real cross-LAN discovery
needs a physical device on the host LAN (the emulator's SLIRP NAT drops mDNS multicast).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M4 Android stage 1 (DualSense feedback, host->client). Two Kotlin poll threads drain the
connector's rumble (0xCA) + HID-output (0xCD) planes via blocking native pulls and render
in Kotlin (Option B — no JNI upcalls, Android APIs stay in Kotlin).
- crates/punktfunk-android: feedback.rs — nativeNextRumble (returns (low<<16)|high, or -1)
+ nativeNextHidout (writes [kind][fields] into a caller's direct ByteBuffer). Ungated; no
new Cargo deps (next_rumble/next_hidout are on the quic feature already).
- clients/android: GamepadFeedback.kt — rumble -> VibratorManager (two-motor amplitude),
HID Led -> lightbar + PlayerLeds -> player LED via LightsManager (API 33+), adaptive
triggers parsed + logged (no public Android API); resolves the connected pad, emulator ->
logged no-op. Started/stopped in the StreamScreen lifecycle (stop + join before nativeClose).
Verified live (emulator -> synthetic host, PUNKTFUNK_TEST_FEEDBACK=1): client received +
decoded the full burst -- rumble low=16384 high=32768, Led r=10 g=20 b=30, PlayerLeds bits=4
player=1, Trigger which=1 mode=0x21 -- matching the host hook exactly. Rendering is a logged
no-op on the emulator (no controller); real haptics/lightbar/player-LED need a physical pad.
Deferred (need a physical DualSense + device enumeration): client->host rich input
(touchpad/motion send_rich_input) and DualSense controller-type negotiation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M4 Android stage 1 (gamepad). One controller forwarded as pad 0; mirrors the
Linux/Apple gamepad mapping (byte-identical GamepadButton/GamepadAxis events).
- crates/punktfunk-android: 2 JNI fns (nativeSendGamepadButton/Axis) building the
GamepadButton/GamepadAxis InputEvents (flags = pad index 0).
- clients/android: Gamepad.kt — BTN_*/AXIS_* wire constants, KEYCODE_*->BTN_* map, and
an AxisMapper (joystick MotionEvent -> sticks +-32767 +y-up / triggers 0..255 /
HAT->BTN_DPAD_* with on-change gating + release-all reset). MainActivity routes
gamepad-source KeyEvents in dispatchKeyEvent (DPAD only when from a gamepad, so
keyboard arrows still map to VK) and adds dispatchGenericMotionEvent for joystick axes.
Verified live (emulator -> gamescope host, `adb input gamepad keyevent`): host created
the virtual X-Box 360 uinput pad (index=0) and received the gamepad datagrams (input=22).
Axes can't be adb-injected (joystick MotionEvents) -- build/clippy + code-review this
increment; live stick/trigger test deferred to a physical controller. Deferred: device
enumeration/selection, controller-type negotiation, DualSense rich input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M4 Android stage 1 (input). Kotlin captures input and forwards it over JNI to
NativeClient::send_input (the connector is linked as a Rust crate).
- crates/punktfunk-android: 4 JNI send fns (pointer move / button / scroll / key)
building InputEvent with the GameStream wire codes — ungated, &self on the Sync
connector (safe from the UI thread).
- clients/android: Keymap.kt (Android KEYCODE_* -> Windows VK, the host's wire
contract, mirroring the Linux/Apple tables); Activity-level dispatchKeyEvent forwards
hardware keys to the active session (above the Compose focus system, so it's reliable);
a Compose touch-trackpad overlay -- 1-finger drag -> relative move, tap -> left click,
2-finger drag -> scroll.
Verified live (emulator -> gamescope host on the LAN box, synthetic `adb input`): host
received 31 input datagrams (input=31) and libei injected KeyDown/KeyUp, MouseButtonDown/Up
and MouseMove all emitted=true. Physical-mouse pointer capture + gamepad are next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M4 Android stage 1 (audio). An audio thread pulls Opus packets from the connector
(next_audio), decodes to interleaved f32 stereo, and feeds AAudio via its realtime
data callback through a jitter ring ported from the Linux client (prime ~3 quanta,
drop-oldest cap, re-prime on drain). All in Rust on native threads — symmetric with
the video decode path.
- crates/punktfunk-android: audio.rs (Opus decode + jitter ring + AAudio callback);
SessionHandle gains an audio slot; nativeStartAudio/nativeStopAudio JNI; Drop stops it.
Android-only deps: opus 0.3 (libopus via cmake, static) + ndk "audio" (AAudio) — pure
C/NDK, no libc++_shared to bundle.
- clients/android: NativeBridge start/stop audio, called in the SurfaceView lifecycle.
- kit/build.gradle.kts: cargo-ndk env for the libopus cmake build (NDK root, Ninja,
LIBOPUS_STATIC/NO_PKG) + --platform 31 (libaaudio is API 26+).
Verified live (emulator -> gamescope host on the LAN box): AAudio opened 48k/stereo/f32;
a 440 Hz tone played into the host capture sink reached the client decoded -- opus ~200/s,
pcm_frames climbing in lockstep, peak=0.089 (real content, not silence), with video
streaming concurrently. Some underruns under emulator jitter (verify on hardware).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M4 Android stage 1 (video). Pull HEVC access units from the connector and render
them to the SurfaceView entirely in Rust (NDK AMediaCodec → ANativeWindow) — no
per-frame JNI, honoring the native-thread hot-path invariant.
- crates/punktfunk-android: decode.rs (one-in/one-out AMediaCodec loop; in-band
VPS/SPS/PPS so no out-of-band csd; dims from NativeClient::mode). SessionHandle
now holds an Arc<NativeClient> + the decode thread; nativeStartVideo/nativeStopVideo.
- clients/android: connect screen (host/port) + full-screen SurfaceView stream
screen — surfaceCreated -> nativeStartVideo, leaving -> stop + close.
Verified live (Android emulator -> m3-host on the LAN box, ABI v2): QUIC handshake,
8-round clock-skew sync, HEVC decoder configured at 1280x720, and the data plane
delivered + fed all 299 access units (the punktfunk/1 NAT hole-punch worked through
the emulator's SLIRP). Real-pixel render is pending a non-synthetic source:
`m3-host --source synthetic` emits dummy transport payloads (not HEVC), so the
decoder correctly produces nothing; `--source virtual` (a compositor on the host)
is needed to verify decode-to-screen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rust-heavy client model (like punktfunk-client-linux): a new cdylib crate
crates/punktfunk-android links punktfunk-core and exposes the JNI seam;
Kotlin (clients/android) owns only the Android-framework surface. Kotlin can't
import the C header the way Swift can, so the bridge is written in Rust to reuse
the Linux client's orchestration rather than re-port it.
- crates/punktfunk-android: JNI bridge — abiVersion/coreVersion native-link
proof + session connect/close handle; plane pumps stubbed for M4 stage 1.
- clients/android: Gradle project — :app (Compose) + :kit (Android library with
a cargo-ndk Exec task -> jniLibs). AGP 9.2 / Gradle 9.4.1 / Kotlin 2.3.21 /
Compose BOM 2026.05.01 / compileSdk 37 / targetSdk 36 / minSdk 31, shipping
arm64-v8a + x86_64. Phone + TV (leanback) installable. README rewritten.
- .gitea/workflows/android.yml: CI mirroring apple.yml on a Linux runner.
- punktfunk-core: switch rcgen to the ring backend so the whole quic tree is
aws-lc-free (smaller client .so, cmake-free cross-compile; a win for all targets).
Validated on this box: :app:assembleDebug -> APK with both ABIs; emulator
first-light renders the bridge linked (core ABI v2) with logcat confirmation;
clippy -D warnings + cargo fmt clean; core tests green on the ring backend.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>