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 a98f48a2..70b8b63a 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 @@ -127,12 +127,12 @@ class MainActivity : ComponentActivity() { if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { val bit = Gamepad.buttonBit(event.keyCode) if (bit != 0) { - // The router forwards the bit on this device's own wire pad index, tracks held - // state per pad, and reports when the emergency-exit chord (Select + Start + L1 + - // R1) completed on any one pad (a couch user has no keyboard/Back). - if (gamepadRouter?.onButton(event, bit) == true) { - requestStreamExit?.let { exit -> window.decorView.post { exit() } } - } + // The router forwards the bit on this device's own wire pad index and tracks held + // state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled + // inside the router: holding it for ~1.5 s fires router.onExitChord (wired in + // StreamScreen), so a couch user with no keyboard/Back can still leave — but an + // accidental brush of the four buttons no longer quits instantly. + gamepadRouter?.onButton(event, bit) return true // consumed } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 83444567..3fbcf4a7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -180,13 +180,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { val router = GamepadRouter(context, handle, initialSettings.gamepad) activity?.gamepadRouter = router // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips - // the keep-alive linger), unlike a host-ended / backgrounded drop. + // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it + // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream + // the same way the Back gesture does. activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + router.onExitChord = { activity?.requestStreamExit?.invoke() } activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // index via the router; poll threads stopped + joined before the router is released and the // session closed. val feedback = GamepadFeedback(handle, router).also { it.start() } + // Free a disconnected controller's rumble/lights bindings promptly (else the open lights + // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. + router.onSlotClosed = feedback::onDeviceRemoved onDispose { closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 608016c6..08c0a431 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -219,14 +219,31 @@ object Gamepad { ), ) - // HAT → dpad button transitions (track previous, emit only the deltas). - val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)) + // HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad + // tap (press+release inside one batch window) lives only in the historical samples — the + // final getAxisValue would show the HAT already back at rest and miss the tap entirely. + // Feed every historical HAT sample (oldest→newest) through the same transition logic + // before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins: + // only the final value matters for an analog axis.) + for (h in 0 until event.historySize) { + applyHat( + sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)), + sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)), + ) + } + applyHat( + sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)), + sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)), + ) + } + + /** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */ + private fun applyHat(hx: Int, hy: Int) { if (hx != hatX) { if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false) if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true) hatX = hx } - val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)) if (hy != hatY) { if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false) if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true) 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 index 3d731053..b82daf75 100644 --- 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 @@ -64,10 +64,16 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute private var rumbleThread: Thread? = null private var hidoutThread: Thread? = null - // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the - // rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the - // main thread AFTER joining those threads (join establishes the happens-before), so plain maps are - // race-free. A null value caches "this controller has no vibrator / no controllable lights". + // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble + // thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes + // from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining + // the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain + // HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the + // null value that caches "this controller has no vibrator / no controllable lights"). The lock + // guards only the map ops — rendering runs on the returned reference outside it; a stale reference + // is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd + // no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed. + private val bindsLock = Any() private val rumbleBinds = HashMap() private val lightBinds = HashMap() @@ -122,13 +128,35 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute rumbleThread = null hidoutThread = null // Threads are dead — drop any held rumble and close every lights session. - for (b in rumbleBinds.values) b?.let { - runCatching { it.vm?.cancel() } - runCatching { it.legacy?.cancel() } + synchronized(bindsLock) { + for (b in rumbleBinds.values) b?.let { + runCatching { it.vm?.cancel() } + runCatching { it.legacy?.cancel() } + } + for (b in lightBinds.values) b?.let { runCatching { it.session.close() } } + rumbleBinds.clear() + lightBinds.clear() + } + } + + /** + * Evict and release the bindings for a controller that just disconnected — invoked from + * [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its + * `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session + * immediately instead of leaking it until [stop]. A no-op for a device with no cached binding. + * The next feedback for that pad index rebinds against whatever controller now holds it. + */ + // Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists + // under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers. + @Suppress("MissingPermission", "NewApi") + fun onDeviceRemoved(deviceId: Int) { + synchronized(bindsLock) { + rumbleBinds.remove(deviceId)?.let { + runCatching { it.vm?.cancel() } + runCatching { it.legacy?.cancel() } + } + lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } } } - for (b in lightBinds.values) b?.let { runCatching { it.session.close() } } - rumbleBinds.clear() - lightBinds.clear() } // ---- Rumble ---- @@ -136,10 +164,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute /** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */ private fun rumbleBindFor(pad: Int): RumbleBind? { val dev = router?.deviceForPad(pad) ?: return null - if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] - val bind = bindRumble(dev) - rumbleBinds[dev.id] = bind - return bind + synchronized(bindsLock) { + if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] + val bind = bindRumble(dev) + rumbleBinds[dev.id] = bind + return bind + } } private fun bindRumble(dev: InputDevice): RumbleBind? { @@ -217,9 +247,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute } // One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it - // self-terminates on a lost stop; cancel on zero. + // self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot` + // throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0 + // (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0) + // stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching, + // so an uncaught throw here would kill the whole rumble poll thread. private fun oneShot(amp: Int, durationMs: Long): VibrationEffect = - VibrationEffect.createOneShot(durationMs, amp) + VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp) // ---- HID output ---- @@ -268,10 +302,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute private fun lightBindFor(pad: Int): LightBind? { if (Build.VERSION.SDK_INT < 33) return null val dev = router?.deviceForPad(pad) ?: return null - if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] - val bind = bindLights(dev) - lightBinds[dev.id] = bind - return bind + synchronized(bindsLock) { + if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] + val bind = bindLights(dev) + lightBinds[dev.id] = bind + return bind + } } private fun bindLights(dev: InputDevice): LightBind? { diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 2e3dee95..ce80b1fe 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -44,6 +44,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */ private val slots = ConcurrentHashMap() + /** + * Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown. + * `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / + * lights bindings are released promptly instead of leaking until the feedback threads stop. + */ + var onSlotClosed: ((deviceId: Int) -> Unit)? = null + + /** + * Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller + * leaves the stream. `StreamScreen` wires this to the deliberate-quit exit. + */ + var onExitChord: (() -> Unit)? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ + private var pendingExit: Runnable? = null + private val inputManager = context.getSystemService(InputManager::class.java) private val listener = object : InputManager.InputDeviceListener { override fun onInputDeviceAdded(deviceId: Int) { @@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett } init { - inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper())) + inputManager?.registerInputDeviceListener(listener, mainHandler) // Open a slot for every controller already connected when the session starts — the pads that // will never fire onInputDeviceAdded during this session; their Arrival lands before any input. for (id in InputDevice.getDeviceIds()) { @@ -66,28 +83,55 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** * One gamepad button transition for the device that produced [event] (already resolved to BTN_* * bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the - * slot's pad index, tracks held state, and returns true when this press completed the emergency - * stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream - * (mirrors the Linux client's escape chord: any one controller can leave). + * slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select + + * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly; + * [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is + * ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave. */ - fun onButton(event: KeyEvent, bit: Int): Boolean { - val slot = slotFor(event.device) ?: return false + fun onButton(event: KeyEvent, bit: Int) { + val slot = slotFor(event.device) ?: return when (event.action) { KeyEvent.ACTION_DOWN -> { // repeatCount guard: don't re-send a held button as auto-repeat. if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) slot.held = slot.held or bit - if (slot.held and EXIT_CHORD == EXIT_CHORD) { - slot.held = 0 - return true - } + // Full chord now held on this pad → start the hold countdown (idempotent while held). + if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit() } KeyEvent.ACTION_UP -> { NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) slot.held = slot.held and bit.inv() + // A chord button lifted before the hold elapsed → cancel, unless another pad still + // holds the full chord. + if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) { + disarmExit() + } } } - return false + } + + /** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */ + private fun armExit() { + if (pendingExit != null) return // already counting down + val r = Runnable { + pendingExit = null + // Fire only if the chord survived the full hold on some pad. + val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD } + if (held.isNotEmpty()) { + // Release the held buttons + zero the axes on every triggering pad so nothing sticks + // host-side once we leave, then signal the deliberate exit. + for (s in held) releaseHeld(s) + onExitChord?.invoke() + } + } + pendingExit = r + mainHandler.postDelayed(r, EXIT_HOLD_MS) + } + + /** Cancel a pending exit-chord hold timer. */ + private fun disarmExit() { + pendingExit?.let { mainHandler.removeCallbacks(it) } + pendingExit = null } /** @@ -124,6 +168,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett */ fun release() { inputManager?.unregisterInputDeviceListener(listener) + disarmExit() // drop any pending exit-chord timer so it can't fire after teardown // Snapshot the ids first — closeSlot mutates the map. for (id in slots.keys.toList()) closeSlot(id) } @@ -173,6 +218,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett val slot = slots.remove(deviceId) ?: return releaseHeld(slot) NativeBridge.nativeSendGamepadRemove(handle, slot.index) + // If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer. + if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit() + // Release this controller's feedback bindings (close its lights session / cancel rumble). + onSlotClosed?.invoke(deviceId) } /** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */ @@ -200,5 +249,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */ const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB + + /** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */ + const val EXIT_HOLD_MS = 1500L } }