Merge remote-tracking branch 'origin/main'
apple / swift (push) Successful in 53s
android / android (push) Successful in 2m10s
ci / rust (push) Failing after 54s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 27s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 1m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
flatpak / build-publish (push) Failing after 2s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m25s
deb / build-publish (push) Successful in 6m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m35s
docker / deploy-docs (push) Successful in 17s

This commit is contained in:
2026-06-15 00:05:58 +00:00
7 changed files with 449 additions and 45 deletions
Generated
+53
View File
@@ -2049,6 +2049,30 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nix" name = "nix"
version = "0.30.1" version = "0.30.1"
@@ -2161,6 +2185,28 @@ dependencies = [
"libc", "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]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.7.1" version = "0.7.1"
@@ -2445,6 +2491,7 @@ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
"log", "log",
"ndk",
"punktfunk-core", "punktfunk-core",
] ]
@@ -2725,6 +2772,12 @@ dependencies = [
"rand_core 0.9.5", "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]] [[package]]
name = "rayon" name = "rayon"
version = "1.12.0" version = "1.12.0"
@@ -1,59 +1,166 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.os.Bundle 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.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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() enableEdgeToEdge()
setContent { setContent {
MaterialTheme(colorScheme = darkColorScheme()) { MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) { App() }
ScaffoldScreen(abi, core)
}
} }
} }
} }
} }
/** 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 @Composable
private fun ScaffoldScreen(abi: Int, core: String) { private fun App() {
var screen by remember { mutableStateOf<Screen>(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<String?>(null) }
val abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) }
val (w, h, hz) = REQUEST_MODE
Column( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text("punktfunk", style = MaterialTheme.typography.headlineMedium) Text("punktfunk", style = MaterialTheme.typography.headlineMedium)
Text("Android client — scaffold", style = MaterialTheme.typography.bodyMedium) Text("Android client", style = MaterialTheme.typography.bodyMedium)
Text( Spacer(Modifier.height(24.dp))
if (abi > 0) "✓ native bridge linked" else "✗ native bridge FAILED", OutlinedTextField(
style = MaterialTheme.typography.titleMedium, 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)
}
})
}
},
)
}
@@ -28,4 +28,13 @@ object NativeBridge {
/** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */ /** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long) 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)
} }
+4
View File
@@ -25,3 +25,7 @@ log = "0.4"
# `ndk` and Oboe/Opus audio later) is only pulled in for the real `*-linux-android` targets. # `ndk` and Oboe/Opus audio later) is only pulled in for the real `*-linux-android` targets.
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14" 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"] }
+138
View File
@@ -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<NativeClient>, window: NativeWindow, shutdown: Arc<AtomicBool>) {
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
}
+2
View File
@@ -21,6 +21,8 @@ use jni::objects::JObject;
use jni::sys::jint; use jni::sys::jint;
use jni::JNIEnv; use jni::JNIEnv;
#[cfg(target_os = "android")]
mod decode;
mod session; mod session;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the /// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
+118 -27
View File
@@ -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`] //! A connected session is a [`SessionHandle`] — an `Arc<NativeClient>` plus the decode thread it
//! drops it, and the connector's `Drop` tears down the worker thread + QUIC connection (RAII). The //! feeds — boxed and handed to Kotlin as an opaque `jlong`. The connector is `Sync`, so the decode
//! client is `Sync`, so the Kotlin side is free to pull each plane from its own thread later. //! 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 //! Wired so far: connect/close + the video plane (HEVC `next_frame` → NDK AMediaCodec → the
//! orchestration from `crates/punktfunk-client-linux`: //! SurfaceView's `ANativeWindow`, see [`crate::decode`]).
//! //!
//! - video: `next_frame` → AnnexB access unit → `AMediaCodec` (NDK, async) → `SurfaceView` //! TODO(M4 Android stage 1): audio (`next_audio` → Opus → Oboe), input (`send_input` /
//! - audio: `next_audio` → Opus decode → jitter ring → Oboe (port `client-linux/src/audio.rs`) //! `send_rich_input`), rumble/HID feedback, pairing/identity (Keystore). Port the orchestration
//! - input: Kotlin capture → `send_input` / `send_rich_input` (VK keymap from `keymap.rs`) //! from `crates/punktfunk-client-linux`.
//! - 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::objects::{JObject, JString};
use jni::sys::{jint, jlong}; use jni::sys::{jint, jlong};
use jni::JNIEnv; use jni::JNIEnv;
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; 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; use std::time::Duration;
/// `NativeBridge.nativeConnect(host, port, width, height, refreshHz): Long`. /// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
/// pub(crate) struct SessionHandle {
/// Trust-on-first-use (no pin) and anonymous (no client identity) — enough to bring up a stream // Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
/// against a host that does not require pairing. Returns an opaque session handle, or `0` on // build (CI's workspace clippy/build) those readers are cfg'd out, so it's intentionally unused.
/// failure (the cause is logged to logcat). #[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub client: Arc<NativeClient>,
video: Mutex<Option<VideoThread>>,
}
struct VideoThread {
shutdown: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
}
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] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>, mut env: JNIEnv<'local>,
@@ -53,13 +80,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
mode, mode,
CompositorPref::Auto, CompositorPref::Auto,
GamepadPref::Auto, GamepadPref::Auto,
0, // bitrate_kbps: let the host choose its default 0, // bitrate_kbps: host default
None, // launch: default app None, // launch: default app
None, // pin: trust on first use None, // pin: trust on first use
None, // identity: anonymous (TODO: Keystore-backed identity + pairing) None, // identity: anonymous (TODO: Keystore-backed identity + pairing)
Duration::from_secs(10), 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) => { Err(e) => {
log::error!("nativeConnect to {host}:{port} failed: {e}"); log::error!("nativeConnect to {host}:{port} failed: {e}");
0 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 /// `NativeBridge.nativeClose(handle)` — drop the session (stops the decode thread, then RAII-tears
/// worker thread + QUIC connection). No-op on a `0` handle. /// down the connector). No-op on `0`.
/// ///
/// # Safety contract /// # Safety contract
/// `handle` must be either `0` or a value previously returned by [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`] /// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
/// and not already closed. Kotlin owns this invariant (one `nativeClose` per non-zero `nativeConnect`). /// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
_env: JNIEnv, _env: JNIEnv,
@@ -80,7 +113,65 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
handle: jlong, handle: jlong,
) { ) {
if handle != 0 { if handle != 0 {
// SAFETY: per the contract above, `handle` is a live `Box<NativeClient>` pointer. // SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
unsafe { drop(Box::from_raw(handle as *mut NativeClient)) }; 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();
} }
} }