feat(android): DualSense host->client feedback — rumble + lightbar/LEDs/triggers
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 54s
android / android (push) Failing after 1m39s
ci / rust (push) Failing after 1m44s
ci / web (push) Successful in 27s
ci / bench (push) Successful in 1m44s
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 4s
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 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Successful in 3m10s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 1m18s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 54s
android / android (push) Failing after 1m39s
ci / rust (push) Failing after 1m44s
ci / web (push) Successful in 27s
ci / bench (push) Successful in 1m44s
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 4s
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 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Successful in 3m10s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 1m18s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s
M4 Android stage 1 (DualSense feedback, host->client). Two Kotlin poll threads drain the connector's rumble (0xCA) + HID-output (0xCD) planes via blocking native pulls and render in Kotlin (Option B — no JNI upcalls, Android APIs stay in Kotlin). - crates/punktfunk-android: feedback.rs — nativeNextRumble (returns (low<<16)|high, or -1) + nativeNextHidout (writes [kind][fields] into a caller's direct ByteBuffer). Ungated; no new Cargo deps (next_rumble/next_hidout are on the quic feature already). - clients/android: GamepadFeedback.kt — rumble -> VibratorManager (two-motor amplitude), HID Led -> lightbar + PlayerLeds -> player LED via LightsManager (API 33+), adaptive triggers parsed + logged (no public Android API); resolves the connected pad, emulator -> logged no-op. Started/stopped in the StreamScreen lifecycle (stop + join before nativeClose). Verified live (emulator -> synthetic host, PUNKTFUNK_TEST_FEEDBACK=1): client received + decoded the full burst -- rumble low=16384 high=32768, Led r=10 g=20 b=30, PlayerLeds bits=4 player=1, Trigger which=1 mode=0x21 -- matching the host hook exactly. Rendering is a logged no-op on the emulator (no controller); real haptics/lightbar/player-LED need a physical pad. Deferred (need a physical DualSense + device enumeration): client->host rich input (touchpad/motion send_rich_input) and DualSense controller-type negotiation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ 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 androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -205,7 +206,10 @@ private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
|
|||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
activity?.streamHandle = handle // route hardware keys to this session
|
activity?.streamHandle = handle // route hardware keys to this session
|
||||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||||
|
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||||
|
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||||
onDispose {
|
onDispose {
|
||||||
|
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
||||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||||
activity?.axisMapper = null
|
activity?.axisMapper = null
|
||||||
activity?.streamHandle = 0L
|
activity?.streamHandle = 0L
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package io.unom.punktfunk.kit
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.hardware.lights.Light
|
||||||
|
import android.hardware.lights.LightState
|
||||||
|
import android.hardware.lights.LightsManager
|
||||||
|
import android.hardware.lights.LightsRequest
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.CombinedVibration
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.InputDevice
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||||
|
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||||
|
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
|
||||||
|
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||||
|
*
|
||||||
|
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||||
|
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
||||||
|
* this MUST run before `nativeClose` frees the session handle.
|
||||||
|
*
|
||||||
|
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none
|
||||||
|
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the
|
||||||
|
* `Log.i` receipt lines fire regardless of rendering hardware.
|
||||||
|
*/
|
||||||
|
class GamepadFeedback(private val handle: Long) {
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "pf.feedback"
|
||||||
|
const val TAG_LED: Byte = 0x01
|
||||||
|
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||||
|
const val TAG_TRIGGER: Byte = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var running = false
|
||||||
|
private var rumbleThread: Thread? = null
|
||||||
|
private var hidoutThread: Thread? = null
|
||||||
|
|
||||||
|
private var vm: VibratorManager? = null
|
||||||
|
private var vibratorIds: IntArray = IntArray(0)
|
||||||
|
private var amplitudeControlled = false
|
||||||
|
|
||||||
|
private var lightsSession: LightsManager.LightsSession? = null
|
||||||
|
private var rgbLight: Light? = null
|
||||||
|
private var playerLight: Light? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val dev = resolvePad()
|
||||||
|
bindRumble(dev)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
bindLights(dev)
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
running = true
|
||||||
|
rumbleThread = Thread({
|
||||||
|
while (running) {
|
||||||
|
val ev = NativeBridge.nativeNextRumble(handle)
|
||||||
|
if (ev < 0L) continue // timeout / closed
|
||||||
|
renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt())
|
||||||
|
}
|
||||||
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
|
hidoutThread = Thread({
|
||||||
|
val buf = ByteBuffer.allocateDirect(64)
|
||||||
|
while (running) {
|
||||||
|
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||||
|
if (n < 0) continue // timeout / closed
|
||||||
|
dispatchHidout(buf, n)
|
||||||
|
}
|
||||||
|
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||||
|
runCatching { rumbleThread?.join(500) }
|
||||||
|
runCatching { hidoutThread?.join(500) }
|
||||||
|
rumbleThread = null
|
||||||
|
hidoutThread = null
|
||||||
|
runCatching { lightsSession?.close() }
|
||||||
|
lightsSession = null
|
||||||
|
rgbLight = null
|
||||||
|
playerLight = null
|
||||||
|
vm = null
|
||||||
|
vibratorIds = IntArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rumble ----
|
||||||
|
|
||||||
|
private fun bindRumble(dev: InputDevice?) {
|
||||||
|
if (dev == null) {
|
||||||
|
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val m = dev.vibratorManager
|
||||||
|
val ids = m.vibratorIds
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vm = m
|
||||||
|
vibratorIds = ids
|
||||||
|
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||||
|
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||||
|
private fun renderRumble(low: Int, high: Int) {
|
||||||
|
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||||
|
val m = vm ?: return
|
||||||
|
val lo = toAmplitude(low)
|
||||||
|
val hi = toAmplitude(high)
|
||||||
|
if (lo == 0 && hi == 0) {
|
||||||
|
m.cancel() // (0,0) = stop
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val combo = CombinedVibration.startParallel()
|
||||||
|
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||||
|
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||||
|
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||||
|
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||||
|
} else {
|
||||||
|
// Single motor or no amplitude control: blend both into one effect.
|
||||||
|
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||||
|
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||||
|
}
|
||||||
|
runCatching { m.vibrate(combo.combine()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||||
|
private fun toAmplitude(v16: Int): Int {
|
||||||
|
val a = (v16 ushr 8) and 0xFF
|
||||||
|
return if (v16 != 0 && a == 0) 1 else a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long one-shot held until the next packet (the host re-sends ~periodically); cancel on zero.
|
||||||
|
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp)
|
||||||
|
|
||||||
|
// ---- HID output ----
|
||||||
|
|
||||||
|
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
||||||
|
buf.rewind()
|
||||||
|
when (buf.get()) { // kind tag
|
||||||
|
TAG_LED -> {
|
||||||
|
val r = buf.get().toInt() and 0xFF
|
||||||
|
val g = buf.get().toInt() and 0xFF
|
||||||
|
val b = buf.get().toInt() and 0xFF
|
||||||
|
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b))
|
||||||
|
}
|
||||||
|
TAG_PLAYER_LEDS -> {
|
||||||
|
val bits = buf.get().toInt() and 0x1F
|
||||||
|
val player = playerIndexForBits(bits)
|
||||||
|
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player)
|
||||||
|
}
|
||||||
|
TAG_TRIGGER -> {
|
||||||
|
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||||
|
val effLen = n - 2
|
||||||
|
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||||
|
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
||||||
|
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||||
|
0b00000 -> 0
|
||||||
|
0b00100 -> 1
|
||||||
|
0b01010 -> 2
|
||||||
|
0b10101 -> 3
|
||||||
|
0b11011 -> 4
|
||||||
|
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindLights(dev: InputDevice?) {
|
||||||
|
if (dev == null) {
|
||||||
|
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lm = dev.lightsManager
|
||||||
|
for (l in lm.lights) {
|
||||||
|
if (rgbLight == null && l.hasRgbControl()) rgbLight = l
|
||||||
|
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l
|
||||||
|
}
|
||||||
|
if (rgbLight == null && playerLight == null) {
|
||||||
|
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lightsSession = lm.openSession()
|
||||||
|
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLightbar(argb: Int) {
|
||||||
|
val s = lightsSession ?: return
|
||||||
|
val l = rgbLight ?: return
|
||||||
|
runCatching {
|
||||||
|
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPlayerId(player: Int) {
|
||||||
|
val s = lightsSession ?: return
|
||||||
|
val l = playerLight ?: return
|
||||||
|
runCatching {
|
||||||
|
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,4 +68,19 @@ object NativeBridge {
|
|||||||
|
|
||||||
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
|
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
|
||||||
|
|
||||||
|
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each
|
||||||
|
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread.
|
||||||
|
*/
|
||||||
|
external fun nativeNextRumble(handle: Long): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||||
|
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits,
|
||||||
|
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed.
|
||||||
|
*/
|
||||||
|
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! Host→client gamepad feedback pulls (Option B): blocking JNI shims that forward to the connector's
|
||||||
|
//! rumble (0xCA) / HID-output (0xCD) planes and return one decoded event. Kotlin owns the poll
|
||||||
|
//! threads + the Android Vibrator/Lights rendering (see `GamepadFeedback.kt`) — no JNI upcalls, no
|
||||||
|
//! `JavaVM` attach, no cached method ids. Mirrors the audio plane's one-thread-per-plane contract,
|
||||||
|
//! except the thread lives in Kotlin and we just expose the blocking pull.
|
||||||
|
//!
|
||||||
|
//! 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 jni::objects::{JByteBuffer, JObject};
|
||||||
|
use jni::sys::{jint, jlong};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use punktfunk_core::quic::HidOutput;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Short blocking timeout: long enough not to busy-spin, short enough that the Kotlin poll thread
|
||||||
|
/// observes its `running=false` flag promptly on teardown.
|
||||||
|
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
||||||
|
const TAG_LED: u8 = 0x01;
|
||||||
|
const TAG_PLAYER_LEDS: u8 = 0x02;
|
||||||
|
const TAG_TRIGGER: u8 = 0x03;
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
||||||
|
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
|
||||||
|
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||||
|
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
||||||
|
/// Led → `[0x01][r][g][b]` (len 4)
|
||||||
|
/// PlayerLeds → `[0x02][bits]` (len 2)
|
||||||
|
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
||||||
|
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||||
|
env: JNIEnv,
|
||||||
|
_this: JObject,
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
out[0] = TAG_LED;
|
||||||
|
out[1] = r;
|
||||||
|
out[2] = g;
|
||||||
|
out[3] = b;
|
||||||
|
4
|
||||||
|
}
|
||||||
|
HidOutput::PlayerLeds { bits, .. } => {
|
||||||
|
if cap < 2 {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
out[0] = TAG_TRIGGER;
|
||||||
|
out[1] = which;
|
||||||
|
out[2..n].copy_from_slice(&effect);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
};
|
||||||
|
n as jint
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ use jni::JNIEnv;
|
|||||||
mod audio;
|
mod audio;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod decode;
|
mod decode;
|
||||||
|
mod feedback;
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user