From de7b8ac282a7274156a0cb595254c5b40f0df581 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 02:03:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20video=20decode=20pipeline=20?= =?UTF-8?q?=E2=80=94=20NDK=20AMediaCodec=20=E2=86=92=20SurfaceView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 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) --- Cargo.lock | 53 +++++++ .../kotlin/io/unom/punktfunk/MainActivity.kt | 143 ++++++++++++++--- .../io/unom/punktfunk/kit/NativeBridge.kt | 9 ++ crates/punktfunk-android/Cargo.toml | 4 + crates/punktfunk-android/src/decode.rs | 138 +++++++++++++++++ crates/punktfunk-android/src/lib.rs | 2 + crates/punktfunk-android/src/session.rs | 145 ++++++++++++++---- 7 files changed, 449 insertions(+), 45 deletions(-) create mode 100644 crates/punktfunk-android/src/decode.rs diff --git a/Cargo.lock b/Cargo.lock index 2592e7c..d1a49e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2049,6 +2049,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nix" version = "0.30.1" @@ -2161,6 +2185,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -2445,6 +2491,7 @@ dependencies = [ "android_logger", "jni", "log", + "ndk", "punktfunk-core", ] @@ -2724,6 +2771,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.12.0" diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 3f3ad89..a988940 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -1,59 +1,166 @@ package io.unom.punktfunk import android.os.Bundle -import android.util.Log +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.WindowManager import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import io.unom.punktfunk.kit.NativeBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Cross the JNI bridge into libpunktfunk_android.so → punktfunk-core. A live ABI version is - // the scaffold's proof the whole native stack is wired (cargo-ndk → jniLibs → APK → - // System.loadLibrary → JNI → core). Logged so it's verifiable headlessly via logcat. - val abi = runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) - val core = runCatching { NativeBridge.coreVersion() }.getOrDefault("?") - Log.i("punktfunk", "native bridge: core ABI v$abi, core $core") - enableEdgeToEdge() setContent { MaterialTheme(colorScheme = darkColorScheme()) { - Surface(modifier = Modifier.fillMaxSize()) { - ScaffoldScreen(abi, core) - } + Surface(modifier = Modifier.fillMaxSize()) { App() } } } } } +/** Scaffold mode requested from the host (WxH@Hz). TODO: derive from the display. */ +private val REQUEST_MODE = Triple(1280, 720, 60) + +private sealed interface Screen { + data object Connect : Screen + data class Stream(val handle: Long) : Screen +} + @Composable -private fun ScaffoldScreen(abi: Int, core: String) { +private fun App() { + var screen by remember { mutableStateOf(Screen.Connect) } + when (val s = screen) { + Screen.Connect -> ConnectScreen(onConnected = { handle -> screen = Screen.Stream(handle) }) + is Screen.Stream -> StreamScreen(s.handle, onDisconnect = { screen = Screen.Connect }) + } +} + +@Composable +private fun ConnectScreen(onConnected: (Long) -> Unit) { + val scope = rememberCoroutineScope() + var host by remember { mutableStateOf("") } + var port by remember { mutableStateOf("9777") } + var connecting by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + val abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) } + val (w, h, hz) = REQUEST_MODE + Column( modifier = Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text("punktfunk", style = MaterialTheme.typography.headlineMedium) - Text("Android client — scaffold", style = MaterialTheme.typography.bodyMedium) - Text( - if (abi > 0) "✓ native bridge linked" else "✗ native bridge FAILED", - style = MaterialTheme.typography.titleMedium, + Text("Android client", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host") }, + singleLine = true, ) - Text("core ABI v$abi · core $core", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = port, + onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) }, + label = { Text("Port") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + Spacer(Modifier.height(16.dp)) + Button( + enabled = !connecting && host.isNotBlank() && port.isNotBlank(), + onClick = { + connecting = true + status = "Connecting to $host:$port…" + scope.launch { + val handle = withContext(Dispatchers.IO) { + NativeBridge.nativeConnect(host.trim(), port.toInt(), w, h, hz) + } + connecting = false + if (handle != 0L) { + onConnected(handle) + } else { + status = "Connection failed — check host/port and logcat" + } + } + }, + ) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") } + status?.let { + Spacer(Modifier.height(12.dp)) + Text(it, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(24.dp)) + Text("core ABI v$abi", style = MaterialTheme.typography.labelSmall) } } + +@Composable +private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) { + val context = LocalContext.current + val window = (context as? ComponentActivity)?.window + + DisposableEffect(handle) { + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // Leaving the stream: stop the decode thread and tear down the session. + NativeBridge.nativeStopVideo(handle) + NativeBridge.nativeClose(handle) + } + } + + BackHandler { onDisconnect() } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + SurfaceView(ctx).apply { + holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + NativeBridge.nativeStartVideo(handle, holder.surface) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + + override fun surfaceDestroyed(holder: SurfaceHolder) { + NativeBridge.nativeStopVideo(handle) + } + }) + } + }, + ) +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 82d6c16..72a0a43 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -28,4 +28,13 @@ object NativeBridge { /** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */ external fun nativeClose(handle: Long) + + /** + * Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs + * entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started. + */ + external fun nativeStartVideo(handle: Long, surface: android.view.Surface) + + /** Stop + join the decode thread without closing the session. No-op on `0`. */ + external fun nativeStopVideo(handle: Long) } diff --git a/crates/punktfunk-android/Cargo.toml b/crates/punktfunk-android/Cargo.toml index 7475ad3..880f170 100644 --- a/crates/punktfunk-android/Cargo.toml +++ b/crates/punktfunk-android/Cargo.toml @@ -25,3 +25,7 @@ log = "0.4" # `ndk` and Oboe/Opus audio later) is only pulled in for the real `*-linux-android` targets. [target.'cfg(target_os = "android")'.dependencies] android_logger = "0.14" +# NDK bindings for the per-frame video path: AMediaCodec (HEVC hardware decode) + ANativeWindow +# (the SurfaceView surface). Links libmediandk/libnativewindow. Decode runs entirely in Rust — no +# per-frame JNI crossing (the "no async / native threads on the hot path" invariant). +ndk = { version = "0.9", features = ["media"] } diff --git a/crates/punktfunk-android/src/decode.rs b/crates/punktfunk-android/src/decode.rs new file mode 100644 index 0000000..370e0db --- /dev/null +++ b/crates/punktfunk-android/src/decode.rs @@ -0,0 +1,138 @@ +//! Android video decode (android-only): pull HEVC access units from the connector and render them +//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI. +//! +//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the +//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated +//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns +//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag. + +use ndk::media::media_codec::{ + DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection, +}; +use ndk::media::media_format::MediaFormat; +use ndk::native_window::NativeWindow; +use punktfunk_core::client::NativeClient; +use punktfunk_core::error::PunktfunkError; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes. +pub fn run(client: Arc, window: NativeWindow, shutdown: Arc) { + let mode = client.mode(); + let codec = match MediaCodec::from_decoder_type("video/hevc") { + Some(c) => c, + None => { + log::error!("decode: no HEVC decoder on this device"); + return; + } + }; + + let mut format = MediaFormat::new(); + format.set_str("mime", "video/hevc"); + format.set_i32("width", mode.width as i32); + format.set_i32("height", mode.height as i32); + // Generous input buffer so a large keyframe AU is never truncated. + format.set_i32( + "max-input-size", + (mode.width * mode.height).max(2_000_000) as i32, + ); + // Ask for the low-latency decode path where the decoder supports it (no reordering buffer). + format.set_i32("low-latency", 1); + + if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) { + log::error!("decode: configure failed: {e}"); + return; + } + if let Err(e) = codec.start() { + log::error!("decode: start failed: {e}"); + return; + } + log::info!( + "decode: HEVC decoder started at {}x{}", + mode.width, + mode.height + ); + + let mut fed: u64 = 0; + let mut rendered: u64 = 0; + while !shutdown.load(Ordering::Relaxed) { + match client.next_frame(Duration::from_millis(5)) { + Ok(frame) => { + if fed == 0 { + let p = &frame.data; + log::info!( + "decode: first AU {} bytes, head {:02x?}", + p.len(), + &p[..p.len().min(6)] + ); + } + fed += 1; + feed(&codec, &frame.data, frame.pts_ns / 1000); + } + Err(PunktfunkError::NoFrame) => {} // timeout — still drain output below + Err(_) => break, // session closed + } + rendered += drain(&codec); + if fed > 0 && fed % 300 == 0 { + log::info!("decode: fed={fed} rendered={rendered}"); + } + } + + let _ = codec.stop(); + log::info!("decode: stopped (fed={fed} rendered={rendered})"); +} + +/// Copy one access unit into a codec input buffer and queue it. +fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) { + match codec.dequeue_input_buffer(Duration::from_millis(10)) { + Ok(DequeuedInputBufferResult::Buffer(mut buf)) => { + let n = { + let dst = buf.buffer_mut(); + let n = au.len().min(dst.len()); + if n < au.len() { + log::warn!( + "decode: AU {} > input buffer {}, truncated", + au.len(), + dst.len() + ); + } + for (slot, &b) in dst.iter_mut().zip(&au[..n]) { + slot.write(b); + } + n + }; + if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) { + log::warn!("decode: queue_input_buffer: {e}"); + } + } + Ok(DequeuedInputBufferResult::TryAgainLater) => { + // No input buffer free right now; the AU is dropped (FEC/keyframes recover). + } + Err(e) => log::warn!("decode: dequeue_input_buffer: {e}"), + } +} + +/// Release any ready output buffers to the surface (render = true), latency-first. Returns the +/// number of frames presented. +fn drain(codec: &MediaCodec) -> u64 { + let mut n = 0; + loop { + match codec.dequeue_output_buffer(Duration::from_millis(0)) { + Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => { + if let Err(e) = codec.release_output_buffer(buf, true) { + log::warn!("decode: release_output_buffer: {e}"); + break; + } + n += 1; + } + // TryAgainLater / OutputFormatChanged / OutputBuffersChanged — nothing to render now. + Ok(_) => break, + Err(e) => { + log::warn!("decode: dequeue_output_buffer: {e}"); + break; + } + } + } + n +} diff --git a/crates/punktfunk-android/src/lib.rs b/crates/punktfunk-android/src/lib.rs index 38a3433..0dc3162 100644 --- a/crates/punktfunk-android/src/lib.rs +++ b/crates/punktfunk-android/src/lib.rs @@ -21,6 +21,8 @@ use jni::objects::JObject; use jni::sys::jint; use jni::JNIEnv; +#[cfg(target_os = "android")] +mod decode; mod session; /// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the diff --git a/crates/punktfunk-android/src/session.rs b/crates/punktfunk-android/src/session.rs index 4cc400f..8e66e96 100644 --- a/crates/punktfunk-android/src/session.rs +++ b/crates/punktfunk-android/src/session.rs @@ -1,33 +1,60 @@ -//! Session handle lifecycle over JNI. +//! Session lifecycle + plane wiring 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. +//! A connected session is a [`SessionHandle`] — an `Arc` plus the decode thread it +//! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode +//! thread pulls the video plane (`next_frame`) directly while Kotlin still holds the handle. //! -//! TODO(M4 Android stage 1): build out the plane pumps + IO on top of this handle. Port the -//! orchestration from `crates/punktfunk-client-linux`: +//! Wired so far: connect/close + the video plane (HEVC `next_frame` → NDK AMediaCodec → the +//! SurfaceView's `ANativeWindow`, see [`crate::decode`]). //! -//! - 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. +//! TODO(M4 Android stage 1): audio (`next_audio` → Opus → Oboe), input (`send_input` / +//! `send_rich_input`), rumble/HID feedback, pairing/identity (Keystore). Port the orchestration +//! from `crates/punktfunk-client-linux`. 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::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; 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). +/// A live session behind the `jlong` handle: the connector + the decode thread it feeds. +pub(crate) struct SessionHandle { + // Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host + // build (CI's workspace clippy/build) those readers are cfg'd out, so it's intentionally unused. + #[cfg_attr(not(target_os = "android"), allow(dead_code))] + pub client: Arc, + video: Mutex>, +} + +struct VideoThread { + shutdown: Arc, + join: Option>, +} + +impl SessionHandle { + /// Signal the decode thread to stop and join it. Idempotent. + fn stop_video(&self) { + if let Some(mut vt) = self.video.lock().unwrap().take() { + vt.shutdown.store(true, Ordering::SeqCst); + if let Some(j) = vt.join.take() { + let _ = j.join(); + } + } + } +} + +impl Drop for SessionHandle { + fn drop(&mut self) { + self.stop_video(); + } +} + +/// `NativeBridge.nativeConnect(host, port, width, height, refreshHz): Long` — trust-on-first-use, +/// anonymous. Returns an opaque session handle, or `0` on failure (logged to logcat). #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>( mut env: JNIEnv<'local>, @@ -53,13 +80,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo mode, CompositorPref::Auto, GamepadPref::Auto, - 0, // bitrate_kbps: let the host choose its default + 0, // bitrate_kbps: host 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, + Ok(client) => { + let handle = SessionHandle { + client: Arc::new(client), + video: Mutex::new(None), + }; + Box::into_raw(Box::new(handle)) as jlong + } Err(e) => { log::error!("nativeConnect to {host}:{port} failed: {e}"); 0 @@ -67,12 +100,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo } } -/// `NativeBridge.nativeClose(handle)` — drop the boxed [`NativeClient`] (RAII shutdown of the -/// worker thread + QUIC connection). No-op on a `0` handle. +/// `NativeBridge.nativeClose(handle)` — drop the session (stops the decode thread, then RAII-tears +/// down the connector). No-op on `0`. /// /// # 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`). +/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`], +/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this). #[no_mangle] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose( _env: JNIEnv, @@ -80,7 +113,65 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose( handle: jlong, ) { if handle != 0 { - // SAFETY: per the contract above, `handle` is a live `Box` pointer. - unsafe { drop(Box::from_raw(handle as *mut NativeClient)) }; + // SAFETY: per the contract, `handle` is a live `Box` pointer. + unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) }; + } +} + +/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an +/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started. +#[cfg(target_os = "android")] +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( + env: JNIEnv, + _this: JObject, + handle: jlong, + surface: JObject, +) { + if handle == 0 { + return; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + let mut guard = h.video.lock().unwrap(); + if guard.is_some() { + return; // already streaming + } + // SAFETY: `env`/`surface` are valid JNI pointers for this call. `as *mut _` bridges any + // jni-sys version skew between the `jni` and `ndk` crates (both are raw `*mut _` pointers). + let window = match unsafe { + ndk::native_window::NativeWindow::from_surface( + env.get_native_interface() as *mut _, + surface.as_raw() as *mut _, + ) + } { + Some(w) => w, + None => { + log::error!("nativeStartVideo: no ANativeWindow from Surface"); + return; + } + }; + let shutdown = Arc::new(AtomicBool::new(false)); + let client = h.client.clone(); + let sd = shutdown.clone(); + let join = std::thread::Builder::new() + .name("pf-decode".into()) + .spawn(move || crate::decode::run(client, window, sd)) + .ok(); + *guard = Some(VideoThread { shutdown, join }); +} + +/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the +/// session). No-op on `0`. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) { + if handle != 0 { + // SAFETY: live handle per the contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.stop_video(); } }