The host store was keyed by `"address:port"`, which had two consequences.
Editing a host's address had to re-key its record — delete the old key,
write the new one — and nothing could hold a durable reference to a host at
all, because the key moved whenever the host did. Profile bindings, pinned
cards and `punktfunk://` shortcuts all need exactly such a reference, so the
store is now keyed by a minted stable id: a lowercase UUID, the same shape
Apple's `StoredHost.id` and the Rust `KnownHost.id` already carry, so a host
reference is one grammar on every platform. The re-key-on-edit dance is
gone; an edit is a plain save.
`clipboardSync` moves from a global onto the record. It was always a
decision about a HOST — whether text on this device may cross to that
machine — and one global for the work box and the couch box was the wrong
shape; the desktop clients have had it per-host since it shipped. It is
edited from the host's Edit sheet, which is also where the profile binding
will live.
Both, plus the minted ids, ride ONE migration pass. The store is being
rewritten anyway and every extra pass is another chance to strand somebody's
hosts. It runs once against real user data, so its pure half is tested
against a verbatim pre-migration blob — every host survives with its pin,
paired flag and MACs, IPv6 addresses included; each lands on its own id;
whatever the global clipboard setting said lands on every host, on or off;
and a second pass over its own output changes nothing.
Re-trusting a host now goes through `KnownHostStore.trust`, which preserves
the existing record's identity and everything the user set on it. Three call
sites used to construct a fresh `KnownHost` — harmless while the key was the
address, but with an id it would have forked the record on every re-pair.
While the wiring was open: `StreamScreen` takes an `ActiveSession` — the
settings the connect actually resolved, plus that host's clipboard answer —
instead of re-reading `SettingsStore` behind its own connect's back. That is
also the seam profiles need.
0.9.0's HUD display stage hard-linked AMediaCodec_setOnFrameRenderedCallback via
ndk-sys believing it API 26; the symbol is API 33 ("Available since Android T").
A cdylib links fine with the dangling import, so it only exploded at
System.loadLibrary on every pre-13 device: UnsatisfiedLinkError, then every
NativeBridge touch throws NoClassDefFoundError — surfacing as "Identity
unavailable: io.unom.punktfunk.kit.NativeBridge", a dead pair button, and no
discovery (reported on a Y700 / Android 12; 13+ devices unaffected).
- decode::install_render_callback now dlsym-resolves the entry point from
libmediandk.so, mirroring try_set_frame_rate; on API < 33 the HUD simply has
no display stage (the pre-0.9.0 behaviour) and the .so loads.
- New scripts/ci/check-android-jni-imports.sh, wired as checkJniImports* gradle
tasks the APK build depends on: fails the build if libpunktfunk_android.so
imports any symbol absent from the NDK's API-28 stubs — `--platform 28` never
enforced this (cdylib links permit undefined symbols), despite the old
comment's claim. Verified: 3 ABIs clean at the 28 floor, and the check flags
the known API-28 symbols when pointed at a 27 floor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cargo's debug profile is not "slower" for this library — it is unusable, and
it invalidated every on-device performance test to date: RustCrypto's AES-GCM
compiles to generic-array iterator closures with per-byte precondition checks
instead of ARMv8 hardware AES. Profiled live on a phone (simpleperf, 62k
samples): ~800 µs of user CPU per 1.4 KB packet — the receive pump pinned
above a full core yet only draining ~1,400 pkt/s of a 1,775 pkt/s (20 Mbps)
stream, 2.3 MB standing in the kernel socket buffer, the latency-bound flush
firing every 2 s forever. With release rust in the same debug APK: pump at
~12 % of a core, socket queue zero, no flushes, 2800x1260@120 streaming clean.
preDebugBuild now depends on cargoNdkRelease; `-PrustDebug` opts back into a
debug-profile native build for sessions that actually step through Rust.
Kotlin debuggability is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring the Android client to parity with Apple's gamepad experience and finish
Wake-on-LAN.
- Console/gamepad home: host carousel, aurora chrome, mTLS game-library coverflow,
and an input-aware legend that switches between gamepad face buttons and a
TV-remote select-ring + arrows based on the last-used input.
- Wake-on-LAN: the fire-and-forget send is upgraded to wait-until-up
(WakeController/WakeOverlay: resend + mDNS poll, 90s timeout, cancel/retry,
fingerprint-matched so a host that cold-boots onto a new DHCP IP still connects),
plus host edit (touch dialog + console form) with an auto-filled MAC.
- Android TV: brand banner (android:banner), density-aware console scaling, D-pad/
remote nav (Up = Settings, Down or the pad Select button = host Options),
emergency stream-exit chord, and 120Hz console refresh.
- Touch UI: settings split into subpages with a tablet NavigationRail, axis-aware
tab animation (horizontal on phones, vertical on the tablet rail), animated
settings navigation, and a licenses screen with a back button + the real
workspace version (read from Cargo.toml).
- Vector Lock/controller icons (no emoji); bundled Geist font.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Play-listing/marketing screenshots of the Compose client rendered on the host JVM
by Roborazzi (Robolectric Native Graphics) — no emulator, GPU, KVM, host, or JNI
core. Five scenes render the REAL composables with embedded mock state under a
forced brand palette (Material You has no wallpaper to seed from on the JVM):
hosts grid, settings, TOFU + PIN dialogs, and the live stats HUD. Validated 5/5
locally.
- New JVM unit-test source set (app/src/test) + Roborazzi/Robolectric test deps;
@Config(sdk=36) is mandatory (no android-all jar for compileSdk 37) and the
animation clock is paused so a text-bearing scene reaches idle.
- kit: `-PskipRustBuild` skips the cargo-ndk native build so the JVM-only test job
needs no Rust/NDK; normal APK/AAR builds are unchanged.
- Widen BrandDark / StatsOverlay to internal so the tests can use them.
- Standalone best-effort tag-gated workflow; PNGs upload as a 30-day artifact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gradle's Exec resolves command[0] via the JVM/daemon's inherited PATH, not
the environment("PATH", …) set on the task (that only reaches the spawned
child). A GUI Android Studio launch — and any daemon it starts — has no
~/.cargo/bin on its PATH, so a bare "cargo" fails with "A problem occurred
starting process 'command 'cargo''". Use the already-computed cargoBin
absolute path; the env PATH still lets cargo/cargo-ndk find their subtools.
Also refresh the README prereqs: add the missing cmake;3.22.1 SDK package
(the cmake crate builds libopus with it) and drop the broken
`brew --prefix openjdk@21` JAVA_HOME hint in favour of `java_home -v 21`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bodies of work in one commit (the rename moved files the fixes also touched).
Naming/structure cleanup (pre-launch):
- Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host,
m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source->
Punktfunk1Options/Punktfunk1Source.
- Clients consolidated out of crates/ into clients/: punktfunk-client-rs->
clients/probe (crate punktfunk-probe), client-linux->clients/linux,
client-windows->clients/windows, punktfunk-android->clients/android/native
(crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI
contract is unchanged). crates/ now holds only core + host.
- Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site,
kept only in docs/implementation-plan.md. docs/m2-plan.md->
docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated.
Client loss-recovery (video froze and never recovered after a brief drop):
- Export punktfunk_connection_frames_dropped through the C ABI (the core already
tracked it for the client keyframe-recovery loop; it was never reachable from
the ABI clients). Regenerated punktfunk_core.h.
- Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll
frames_dropped and request a keyframe when it climbs -- the same loss-driven
recovery Linux/Windows already had. Under infinite GOP the decoder silently
conceals reference-missing frames, so the decode-error trigger rarely fires.
Apple rumble robustness (worked then went spotty -- DualSense + Xbox):
- Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio
interruption / server reset) and drop the permanent `broken` latch on a
transient drive failure; latch only when the controller truly has no haptics.
- Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging.
Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift.
Not runnable on this box (verify in CI): Gitea workflows, gradle/Android,
flatpak, Swift/decky.
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 (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>
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>