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>