feat(android): scaffold the native Android client (Rust-heavy JNI bridge)
apple / swift (push) Successful in 52s
ci / docs-site (push) Successful in 27s
android / android (push) Successful in 4m52s
ci / web (push) Successful in 26s
ci / bench (push) Successful in 1m33s
ci / rust (push) Successful in 6m56s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m54s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m29s
deb / build-publish (push) Successful in 6m46s
docker / deploy-docs (push) Successful in 22s

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>
This commit is contained in:
2026-06-15 01:37:46 +02:00
parent c9e90d4a59
commit 79217eb93d
24 changed files with 1040 additions and 15 deletions
+86
View File
@@ -0,0 +1,86 @@
//! Session handle lifecycle over JNI.
//!
//! A connected [`NativeClient`] is boxed and handed to Kotlin as an opaque `jlong`; [`nativeClose`]
//! drops it, and the connector's `Drop` tears down the worker thread + QUIC connection (RAII). The
//! client is `Sync`, so the Kotlin side is free to pull each plane from its own thread later.
//!
//! TODO(M4 Android stage 1): build out the plane pumps + IO on top of this handle. Port the
//! orchestration from `crates/punktfunk-client-linux`:
//!
//! - video: `next_frame` → AnnexB access unit → `AMediaCodec` (NDK, async) → `SurfaceView`
//! - audio: `next_audio` → Opus decode → jitter ring → Oboe (port `client-linux/src/audio.rs`)
//! - input: Kotlin capture → `send_input` / `send_rich_input` (VK keymap from `keymap.rs`)
//! - rumble/HID feedback: `next_rumble` / `next_hidout` → VibratorManager / LightsManager
//! - trust: `generate_identity` + `pair` + pin (Keystore-wrapped), then pass `pin`/`identity` here
//!
//! The signatures below are deliberately minimal (TOFU, anonymous) so the scaffold can already
//! stand up a session against a host that does not require pairing.
use jni::objects::{JObject, JString};
use jni::sys::{jint, jlong};
use jni::JNIEnv;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::time::Duration;
/// `NativeBridge.nativeConnect(host, port, width, height, refreshHz): Long`.
///
/// Trust-on-first-use (no pin) and anonymous (no client identity) — enough to bring up a stream
/// against a host that does not require pairing. Returns an opaque session handle, or `0` on
/// failure (the cause is logged to logcat).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
_this: JObject<'local>,
host: JString<'local>,
port: jint,
width: jint,
height: jint,
refresh_hz: jint,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
Err(_) => return 0,
};
let mode = Mode {
width: width as u32,
height: height as u32,
refresh_hz: refresh_hz as u32,
};
match NativeClient::connect(
&host,
port as u16,
mode,
CompositorPref::Auto,
GamepadPref::Auto,
0, // bitrate_kbps: let the host choose its default
None, // launch: default app
None, // pin: trust on first use
None, // identity: anonymous (TODO: Keystore-backed identity + pairing)
Duration::from_secs(10),
) {
Ok(client) => Box::into_raw(Box::new(client)) as jlong,
Err(e) => {
log::error!("nativeConnect to {host}:{port} failed: {e}");
0
}
}
}
/// `NativeBridge.nativeClose(handle)` — drop the boxed [`NativeClient`] (RAII shutdown of the
/// worker thread + QUIC connection). No-op on a `0` handle.
///
/// # Safety contract
/// `handle` must be either `0` or a value previously returned by [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`]
/// and not already closed. Kotlin owns this invariant (one `nativeClose` per non-zero `nativeConnect`).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_env: JNIEnv,
_this: JObject,
handle: jlong,
) {
if handle != 0 {
// SAFETY: per the contract above, `handle` is a live `Box<NativeClient>` pointer.
unsafe { drop(Box::from_raw(handle as *mut NativeClient)) };
}
}