diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index d361cbb..297e151 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import io.unom.punktfunk.kit.Gamepad +import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.Keymap import io.unom.punktfunk.kit.NativeBridge import kotlin.math.abs @@ -205,7 +206,10 @@ private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) { window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) activity?.streamHandle = handle // route hardware keys to this session 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 { + 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 = null activity?.streamHandle = 0L diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt new file mode 100644 index 0000000..2a6e717 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -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()) + } + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index fbab0d3..17311f3 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -68,4 +68,19 @@ object NativeBridge { /** 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) + + // ---- 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 } diff --git a/crates/punktfunk-android/src/feedback.rs b/crates/punktfunk-android/src/feedback.rs new file mode 100644 index 0000000..e374199 --- /dev/null +++ b/crates/punktfunk-android/src/feedback.rs @@ -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 +} diff --git a/crates/punktfunk-android/src/lib.rs b/crates/punktfunk-android/src/lib.rs index 5093617..df65154 100644 --- a/crates/punktfunk-android/src/lib.rs +++ b/crates/punktfunk-android/src/lib.rs @@ -25,6 +25,7 @@ use jni::JNIEnv; mod audio; #[cfg(target_os = "android")] mod decode; +mod feedback; mod session; /// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the