feat(gamepad): add virtual Xbox One/Series + DualShock 4 pad types
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a physical Xbox One or PS4 pad on the client gets a near-native matching virtual pad on the host, auto-resolved from the controller type. Protocol/core: - GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/ from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants (compile-time guard ties them to the enum). Single-byte wire form is unchanged, so it's forward-compatible (older peers degrade to Auto). Host (Linux): - New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation: lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers / player LEDs / mute. Reuses the DualSense pure state + button mapping; only the report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake (0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane, lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane. - Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise. - PadBackend dispatch + resolver handle both; off Linux the UHID pads and One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred. Clients (auto-resolve physical pad -> virtual type, plus manual settings): - Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE -> Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture + lightbar already type-agnostic. Linux settings combo + label updated. - Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4 touchpad capture, settings picker entries. - Android (Kotlin): InputDevice VID/PID auto-detect (matching the other clients) + settings entries. - probe: --gamepad help/aliases. Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in catch_unwind so a panic degrades to a logged no-op instead of aborting the app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||
@@ -143,11 +144,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
val handle = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** index = GamepadPref wire byte. */
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
)
|
||||
|
||||
@@ -44,6 +44,71 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
const val PREF_DUALSENSE = 2
|
||||
const val PREF_XBOXONE = 3
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the
|
||||
* Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type
|
||||
* auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` /
|
||||
* `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as
|
||||
* is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the
|
||||
* host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a
|
||||
* physical pad we resolve it concretely, matching the other native clients.
|
||||
*/
|
||||
fun prefFor(dev: InputDevice?): Int {
|
||||
if (dev == null) return PREF_XBOX360
|
||||
val vid = dev.vendorId
|
||||
val pid = dev.productId
|
||||
return when {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||
* type from the first connected controller via [prefFor] (so the host gets the right pad even
|
||||
* though Android can't tell it the controller type any other way).
|
||||
*/
|
||||
fun resolvePref(setting: Int): Int =
|
||||
if (setting == PREF_AUTO) prefFor(firstPad()) else setting
|
||||
|
||||
/**
|
||||
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
|
||||
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
|
||||
|
||||
@@ -81,8 +81,16 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { rumbleThread?.join(200) }
|
||||
runCatching { hidoutThread?.join(200) }
|
||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
||||
runCatching { rumbleThread?.join() }
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
runCatching { lightsSession?.close() }
|
||||
@@ -94,18 +102,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||
private fun resolvePad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
||||
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
||||
|
||||
use crate::session::SessionHandle;
|
||||
use crate::session::{jni_guard, SessionHandle};
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
@@ -32,17 +32,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jlong {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||
@@ -58,57 +61,60 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
handle: jlong,
|
||||
buf: JByteBuffer,
|
||||
) -> jint {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return -1, // timeout or closed — Kotlin loops
|
||||
};
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return -1, // timeout or closed — Kotlin loops
|
||||
};
|
||||
|
||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return -1,
|
||||
};
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return -1,
|
||||
};
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
|
||||
let n = match ev {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
if cap < 4 {
|
||||
return -1;
|
||||
let n = match ev {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
if cap < 4 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_LED;
|
||||
out[1] = r;
|
||||
out[2] = g;
|
||||
out[3] = b;
|
||||
4
|
||||
}
|
||||
out[0] = TAG_LED;
|
||||
out[1] = r;
|
||||
out[2] = g;
|
||||
out[3] = b;
|
||||
4
|
||||
}
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
if cap < 2 {
|
||||
return -1;
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
if cap < 2 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_PLAYER_LEDS;
|
||||
out[1] = bits;
|
||||
2
|
||||
}
|
||||
out[0] = TAG_PLAYER_LEDS;
|
||||
out[1] = bits;
|
||||
2
|
||||
}
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let n = 2 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let n = 2 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
}
|
||||
out[0] = TAG_TRIGGER;
|
||||
out[1] = which;
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
out[0] = TAG_TRIGGER;
|
||||
out[1] = which;
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,11 +19,28 @@ use jni::JNIEnv;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead.
|
||||
///
|
||||
/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 —
|
||||
/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C
|
||||
/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the
|
||||
/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work.
|
||||
/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an
|
||||
/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged
|
||||
/// no-op rather than kill the app.
|
||||
pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| {
|
||||
log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)");
|
||||
default
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -231,10 +248,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
||||
}
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
@@ -367,11 +386,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_video();
|
||||
}
|
||||
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.
|
||||
@@ -386,36 +407,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jdoubleArray {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let snap = match h.video.lock().unwrap().as_ref() {
|
||||
Some(vt) => vt.stats.drain(),
|
||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||
};
|
||||
let mode = h.client.mode();
|
||||
let buf: [f64; 10] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
snap.lat_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,
|
||||
];
|
||||
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()
|
||||
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 snap = match h.video.lock().unwrap().as_ref() {
|
||||
Some(vt) => vt.stats.drain(),
|
||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||
};
|
||||
let mode = h.client.mode();
|
||||
let buf: [f64; 10] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
snap.lat_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,
|
||||
];
|
||||
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.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||
@@ -451,11 +474,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_audio();
|
||||
}
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_audio();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
||||
@@ -492,11 +517,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_mic();
|
||||
}
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_mic();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
|
||||
|
||||
Reference in New Issue
Block a user