The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.
Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.
Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.
env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
so a child-spawning thread (child spawn snapshots the env block) was live while
`set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
parallel tests could race the write; the set now happens exactly once inside
`get_or_init`.
cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).
rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.
Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.
Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
635 lines
29 KiB
Rust
635 lines
29 KiB
Rust
//! Plane start/stop: video (HEVC decode → Surface), host→client audio, mic uplink — plus the
|
||
//! ~1 Hz decode-stats drain for the HUD.
|
||
|
||
use jni::objects::JObject;
|
||
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
|
||
#[cfg(target_os = "android")]
|
||
use jni::objects::JString;
|
||
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
|
||
use jni::JNIEnv;
|
||
|
||
use super::{jni_guard, lock_recover, SessionHandle};
|
||
|
||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||
/// and start the decode thread rendering onto it. `decoderName` is the codec Kotlin ranked from
|
||
/// `MediaCodecList` (`""` = let the platform resolve the default for the MIME); `lowLatencyMode`
|
||
/// is the user's master toggle; `lowLatencyFeature` is whether that decoder advertised
|
||
/// `FEATURE_LowLatency` (HUD label only); `presentPriority`/`smoothBuffer` are the timeline
|
||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||
/// No-op if already started.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||
mut env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
surface: JObject,
|
||
decoder_name: JString,
|
||
low_latency_mode: jboolean,
|
||
ll_feature: jboolean,
|
||
is_tv: jboolean,
|
||
present_priority: jni::sys::jint,
|
||
smooth_buffer: jni::sys::jint,
|
||
panel_fps: jni::sys::jint,
|
||
) {
|
||
use super::VideoThread;
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::Arc;
|
||
|
||
if handle == 0 {
|
||
return;
|
||
}
|
||
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
|
||
let decoder = env
|
||
.get_string(&decoder_name)
|
||
.ok()
|
||
.map(String::from)
|
||
.filter(|s| !s.is_empty());
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mut guard = lock_recover(&h.video);
|
||
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 st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
|
||
let opts = crate::decode::DecodeOptions {
|
||
decoder_name: decoder,
|
||
ll_feature: ll_feature != 0,
|
||
low_latency_mode: low_latency_mode != 0,
|
||
is_tv: is_tv != 0,
|
||
present_priority,
|
||
smooth_buffer,
|
||
panel_hz: panel_fps,
|
||
};
|
||
let join = std::thread::Builder::new()
|
||
.name("pf-decode".into())
|
||
.spawn(move || crate::decode::run(client, window, sd, st, opts))
|
||
.ok();
|
||
*guard = Some(VideoThread { shutdown, join });
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
|
||
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
|
||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
|
||
env: JNIEnv<'local>,
|
||
_this: JObject<'local>,
|
||
handle: jlong,
|
||
) -> jstring {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
|
||
Ok(s) => s.into_raw(),
|
||
Err(_) => std::ptr::null_mut(),
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the
|
||
/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed
|
||
/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME
|
||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||
env: JNIEnv<'local>,
|
||
_this: JObject<'local>,
|
||
handle: jlong,
|
||
) -> jstring {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
match env.new_string(crate::decode::codec_label(h.client.codec)) {
|
||
Ok(s) => s.into_raw(),
|
||
Err(_) => std::ptr::null_mut(),
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
|
||
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
|
||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
|
||
/// device).
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
|
||
env: JNIEnv<'local>,
|
||
_this: JObject<'local>,
|
||
handle: jlong,
|
||
) -> jstring {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
match env.new_string(h.stats.decoder_label()) {
|
||
Ok(s) => s.into_raw(),
|
||
Err(_) => std::ptr::null_mut(),
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||
/// session). No-op on `0`.
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.stop_video();
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
|
||
/// (unified stats spec, `design/stats-unification.md`). Returns 35 doubles
|
||
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
|
||
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
|
||
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
|
||
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
|
||
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
|
||
/// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original
|
||
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
|
||
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
|
||
/// are the Phase-2 split of the `host+network` term from the per-AU 0xCF host timings — `host` =
|
||
/// the host's capture→sent, `network` = the remainder — both 0.0 when no timing matched this
|
||
/// window, i.e. an old host; 18–21 are the spec's per-window line-4 counters — `lost` =
|
||
/// unrecoverable drops this window, `skipped` = client newest-wins/pacing drops, `fec` = shards
|
||
/// recovered, `frames` = AUs received, so the HUD can compute `lost/(frames+lost)` — index 9 stays
|
||
/// the cumulative session total for older readers; 22–25 are the `display` stage from the
|
||
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
|
||
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
|
||
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
|
||
/// the capture→decoded headline at 2/3; 26–29 are the timeline presenter's split of the `display`
|
||
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
|
||
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
|
||
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
|
||
/// is active this session; 30/31 are the `decode` stage's split p50s — `feed` =
|
||
/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure,
|
||
/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the
|
||
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
|
||
/// newest-wins pacing); 33/34 are the AUDIO plane's latency — the playback ring's live depth in ms
|
||
/// and the A/V sync loop's smoothed offset in ms (positive = audio behind the picture) — both live
|
||
/// gauges rather than windowed samples, like the cumulative drop total at 9), or `null` when no
|
||
/// decode thread is running.
|
||
/// Poll ~1 Hz from the UI; each call
|
||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||
/// the host build too (Kotlin only ever calls it on device).
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||
env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) -> jdoubleArray {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
if lock_recover(&h.video).is_none() {
|
||
return std::ptr::null_mut(); // not streaming → no stats
|
||
}
|
||
let snap = h
|
||
.stats
|
||
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
|
||
let mode = h.client.mode();
|
||
let color = h.client.color;
|
||
let buf: [f64; 35] = [
|
||
snap.fps,
|
||
snap.mbps,
|
||
snap.e2e_p50_ms,
|
||
snap.e2e_p95_ms,
|
||
if snap.lat_valid { 1.0 } else { 0.0 },
|
||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||
mode.width as f64,
|
||
mode.height as f64,
|
||
mode.refresh_hz as f64,
|
||
h.client.frames_dropped() as f64,
|
||
// Video-feed properties the host resolved at the handshake (Welcome): encode bit depth
|
||
// (8 / 10), the CICP colour primaries + transfer code points (Kotlin maps these to a
|
||
// colour-space / HDR label — transfer 16 = PQ, 18 = HLG ⇒ HDR), and the HEVC
|
||
// chroma_format_idc (1 = 4:2:0, 3 = 4:4:4). Static for the session unless renegotiated.
|
||
h.client.bit_depth as f64,
|
||
color.primaries as f64,
|
||
color.transfer as f64,
|
||
h.client.chroma_format as f64,
|
||
// Stage p50s tiling the end-to-end headline (appended to keep 0–13 index-compatible).
|
||
snap.hostnet_p50_ms,
|
||
snap.decode_p50_ms,
|
||
// Phase-2 host/network split of the `host+network` stage (0xCF host timings): 0.0
|
||
// when no timing matched this window (old host) — the HUD keeps the combined term.
|
||
snap.host_p50_ms,
|
||
snap.net_p50_ms,
|
||
// Spec line-4 counters, per-window: lost (unrecoverable drops), skipped (client
|
||
// newest-wins/pacing drops), FEC shards recovered, and the received-AU count so the
|
||
// HUD computes the loss percentage `lost/(frames+lost)` exactly.
|
||
snap.lost as f64,
|
||
snap.skipped as f64,
|
||
snap.fec as f64,
|
||
snap.frames as f64,
|
||
// `display` stage (OnFrameRendered render timestamps): validity flag, the
|
||
// decoded→displayed stage p50, and the directly-measured capture→displayed headline
|
||
// pair that supersedes 2/3 whenever the flag is set (spec: the equation always tiles
|
||
// the headline interval, so endpoint and terms move together).
|
||
if snap.disp_valid { 1.0 } else { 0.0 },
|
||
snap.display_p50_ms,
|
||
snap.e2e_disp_p50_ms,
|
||
snap.e2e_disp_p95_ms,
|
||
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
|
||
// release→displayed), the window's on-glass confirm count, and whether the presenter
|
||
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
|
||
snap.pace_p50_ms,
|
||
snap.latch_p50_ms,
|
||
snap.presents as f64,
|
||
if h.stats.presenter_active() { 1.0 } else { 0.0 },
|
||
// The `decode` stage's split (P3 science): feed = received→queued (hand-off +
|
||
// input-slot wait), codec = queued→decoded (codec-pure) — and the parked-AU
|
||
// overflow subset of `skipped` (decoder-health vs benign pacing drops).
|
||
snap.feed_p50_ms,
|
||
snap.codec_p50_ms,
|
||
snap.skipped_overflow as f64,
|
||
// The audio plane's own latency (`design/audio-latency-overhaul.md`): how much decoded
|
||
// audio is queued ahead of the speaker, and where the A/V sync loop measures that
|
||
// PUTS it relative to the picture (+ = audio behind). Both, because a deep ring on a
|
||
// jittery link is correct behaviour and only the offset tells that apart from audio
|
||
// simply held late. Live gauges written by the audio thread — before this the whole
|
||
// plane published nothing any surface could render, so a "the audio delay is way too
|
||
// high" report had no instrument behind it at all.
|
||
h.client.audio_buffer_ms() as f64,
|
||
h.client.audio_av_offset_ms() as f64,
|
||
];
|
||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||
Ok(a) => a,
|
||
Err(_) => return std::ptr::null_mut(),
|
||
};
|
||
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
||
return std::ptr::null_mut();
|
||
}
|
||
arr.into_raw()
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
|
||
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
|
||
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
|
||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||
/// build too. Cheap; safe on the UI thread.
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||
env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) -> jintArray {
|
||
jni_guard(std::ptr::null_mut(), || {
|
||
if handle == 0 {
|
||
return std::ptr::null_mut();
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mode = h.client.mode();
|
||
let buf: [i32; 3] = [
|
||
mode.width as i32,
|
||
mode.height as i32,
|
||
mode.refresh_hz as i32,
|
||
];
|
||
let arr = match env.new_int_array(buf.len() as jsize) {
|
||
Ok(a) => a,
|
||
Err(_) => return std::ptr::null_mut(),
|
||
};
|
||
if env.set_int_array_region(&arr, 0, &buf).is_err() {
|
||
return std::ptr::null_mut();
|
||
}
|
||
arr.into_raw()
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
|
||
/// HUD actually being visible: while disabled the decode thread skips the clock read + lock per AU.
|
||
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
||
/// pure `jni` + an atomic store, so it links on the host build too.
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
enabled: jboolean,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
// The current cumulative counters seed the window baselines, so the first snapshot's
|
||
// `lost`/`FEC` cover only time the HUD was actually up.
|
||
h.stats.set_enabled(
|
||
enabled != 0,
|
||
h.client.frames_dropped(),
|
||
h.client.fec_recovered_shards(),
|
||
);
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
|
||
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
|
||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||
/// streaming.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
low_latency_mode: jboolean,
|
||
) {
|
||
if handle == 0 {
|
||
return;
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mut guard = lock_recover(&h.audio);
|
||
if guard.is_some() {
|
||
return; // already playing
|
||
}
|
||
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode != 0) {
|
||
Some(p) => *guard = Some(p),
|
||
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
|
||
}
|
||
}
|
||
|
||
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
||
/// closing the session). No-op on `0`.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.stop_audio();
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStartMic(handle, echoCancel): Int` — start mic capture (AAudio input →
|
||
/// Opus → host `send_mic`). `echoCancel` opens the capture under the `VoiceCommunication` preset
|
||
/// (the HAL's echo canceller / noise suppressor) and allocates an audio session id; the return
|
||
/// value is that id (`> 0`), so Kotlin can attach the Java `AcousticEchoCanceler`/`NoiseSuppressor`
|
||
/// as a backstop — `0` when none was allocated (echoCancel off, the preset fell back to the plain
|
||
/// open, a `0` handle, or the mic failed entirely). Already running (a surface recreate) returns
|
||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||
/// the rest of the session streaming.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
echo_cancel: jboolean,
|
||
) -> jni::sys::jint {
|
||
if handle == 0 {
|
||
return 0;
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
let mut guard = lock_recover(&h.mic);
|
||
if let Some(m) = guard.as_ref() {
|
||
return m.session_id(); // already capturing — same stream, same session
|
||
}
|
||
// The capture SHARES the session's mute flag, so one started while muted stays muted (and
|
||
// sends nothing) from its very first frame — see `SessionHandle::mic_muted`.
|
||
match crate::mic::MicCapture::start(h.client.clone(), echo_cancel != 0, h.mic_muted.clone()) {
|
||
Some(m) => {
|
||
let session_id = m.session_id();
|
||
*guard = Some(m);
|
||
session_id
|
||
}
|
||
None => {
|
||
log::error!("nativeStartMic: mic init failed (RECORD_AUDIO? — session unaffected)");
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `NativeBridge.nativeStopMic(handle)` — stop + join the mic thread and close the AAudio input
|
||
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
|
||
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.stop_mic();
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||
///
|
||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||
///
|
||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||
/// app-side fix worth blocking a session on.
|
||
#[unsafe(no_mangle)]
|
||
#[cfg(target_os = "android")]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
pad: jni::sys::jint,
|
||
fd: jni::sys::jint,
|
||
haptics: jboolean,
|
||
speaker: jboolean,
|
||
) -> jboolean {
|
||
jni_guard(0, || {
|
||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||
return 0;
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||
// can never hold the same descriptor at once.
|
||
h.stop_pad_audio();
|
||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||
// rumble and give it nothing in return — no haptics of any kind.
|
||
match crate::pad_audio::start(
|
||
std::sync::Arc::clone(&h.client),
|
||
pad as u8,
|
||
fd,
|
||
haptics != 0,
|
||
speaker != 0,
|
||
) {
|
||
Some(p) => {
|
||
*lock_recover(&h.pad_audio) = Some(p);
|
||
1
|
||
}
|
||
None => 0,
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||
/// tone through the real client render path, with no host and no session involved.
|
||
///
|
||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||
#[unsafe(no_mangle)]
|
||
#[cfg(target_os = "android")]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
fd: jni::sys::jint,
|
||
seconds: jni::sys::jint,
|
||
hz: jni::sys::jint,
|
||
) -> jni::sys::jint {
|
||
jni_guard(-1, || {
|
||
if fd < 0 {
|
||
return -1;
|
||
}
|
||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||
///
|
||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||
#[unsafe(no_mangle)]
|
||
#[cfg(target_os = "android")]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
pad: jni::sys::jint,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.stop_pad_audio();
|
||
if (0..16).contains(&pad) {
|
||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||
///
|
||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||
/// it settled on and its primed buffers all stay exactly as they are, and the encode loop simply
|
||
/// drops each 10 ms frame instead of encoding + sending it. A stop/start would re-run the preset
|
||
/// fallback ladder and re-prime buffers on every toggle — hundreds of ms, and possibly a different
|
||
/// rung (echo cancellation silently lost). This way a toggle costs one atomic store here and one
|
||
/// relaxed load per frame there, and takes effect on the very next 10 ms boundary.
|
||
///
|
||
/// Sticky for the SESSION (the flag lives on the handle, not on the capture), so the mic restart a
|
||
/// surface recreate performs comes back muted with no window for an unmuted frame to escape; a
|
||
/// fresh session always starts unmuted. No-op on `0`. Not android-gated — pure `jni` + an atomic
|
||
/// store, so it links on the host build too.
|
||
///
|
||
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
|
||
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
|
||
/// no captured audio leaves the process.
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
muted: jboolean,
|
||
) {
|
||
jni_guard((), || {
|
||
if handle != 0 {
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
h.mic_muted
|
||
.store(muted != 0, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
})
|
||
}
|
||
|
||
/// `NativeBridge.nativeMicActive(handle): Boolean` — is a mic capture actually RUNNING? `true` only
|
||
/// between a `nativeStartMic` that opened a stream and the matching `nativeStopMic`. The in-stream
|
||
/// mute control is offered on this evidence rather than on the user's setting, so a device that
|
||
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
|
||
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
|
||
#[cfg(target_os = "android")]
|
||
#[unsafe(no_mangle)]
|
||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||
_env: JNIEnv,
|
||
_this: JObject,
|
||
handle: jlong,
|
||
) -> jboolean {
|
||
jni_guard(0, || {
|
||
if handle == 0 {
|
||
return 0;
|
||
}
|
||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||
jboolean::from(lock_recover(&h.mic).is_some())
|
||
})
|
||
}
|