fix(gamepad/android): batched HAT, rumble-duration floor, bind eviction, held exit chord (G4/G9/G18/G24)

Four Android gamepad fixes bringing the client to parity with SDL/Apple:

G4 — HAT batched history. Android batches joystick ACTION_MOVEs, so a
rapid d-pad tap (press+release within one batch) lived only in the event's
historical samples; onMotion read just the final getAxisValue and missed
it. Feed every historical HAT sample through the transition logic (new
`applyHat`) before the current one. Sticks/triggers stay latest-wins.

G9 — floor the rumble one-shot duration. A v2 lease can carry ttl_ms==0
with a nonzero amplitude (past the (0,0) stop guard); createOneShot throws
on a non-positive duration, and on the VibratorManager path the effect is
built outside the vibrate() runCatching, so the throw would kill the whole
rumble poll thread. `durationMs.coerceAtLeast(1)`.

G18 — evict feedback binds on disconnect. Rumble/light bindings were
cached by device id and freed only at session stop, so a controller
unplugged mid-session leaked its open LightsSession. Add
GamepadFeedback.onDeviceRemoved(deviceId) (closes the session, cancels
rumble), invoked from GamepadRouter's slot-close via a new onSlotClosed
callback wired in StreamScreen. The bind maps are now guarded by a lock
(the poll threads write them; eviction runs on the main thread).

G24 — held exit chord + releases. The emergency-exit chord (Select+Start+
L1+R1) quit the stream the instant it completed — an accidental brush
killed the session, and the four held buttons were never released
host-side. Now completing the chord ARMS a 1.5 s hold timer (matching
DISCONNECT_HOLD on SDL/Apple); onExitChord fires only if still held at
expiry, after releasing the held buttons + zeroing the axes on the
triggering pad(s). onButton no longer returns the exit bool (async now);
MainActivity + StreamScreen updated.

G25 (Android half): no change — Android's stick/trigger `.toInt()` already
truncates, the chosen cross-client convention. G23 (rich-input plane) stays
deferred to its own doc.

Verified on this Mac: :kit + :app compileDebugKotlin clean; kit lint
unchanged at its pre-existing baseline. On-glass on a real phone + pad
still owed (per the Android-regressions-only-show-on-hardware history):
watch batched d-pad taps, the 1.5 s exit hold, and a mid-session unplug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 18:04:30 +02:00
parent 59fc820226
commit 48933dc405
5 changed files with 152 additions and 41 deletions
@@ -127,12 +127,12 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.buttonBit(event.keyCode) val bit = Gamepad.buttonBit(event.keyCode)
if (bit != 0) { if (bit != 0) {
// The router forwards the bit on this device's own wire pad index, tracks held // The router forwards the bit on this device's own wire pad index and tracks held
// state per pad, and reports when the emergency-exit chord (Select + Start + L1 + // state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
// R1) completed on any one pad (a couch user has no keyboard/Back). // inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
if (gamepadRouter?.onButton(event, bit) == true) { // StreamScreen), so a couch user with no keyboard/Back can still leave — but an
requestStreamExit?.let { exit -> window.decorView.post { exit() } } // accidental brush of the four buttons no longer quits instantly.
} gamepadRouter?.onButton(event, bit)
return true // consumed return true // consumed
} }
} }
@@ -180,13 +180,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val router = GamepadRouter(context, handle, initialSettings.gamepad) val router = GamepadRouter(context, handle, initialSettings.gamepad)
activity?.gamepadRouter = router activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips // 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() } activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
router.onExitChord = { activity?.requestStreamExit?.invoke() }
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate 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 // 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 // index via the router; poll threads stopped + joined before the router is released and the
// session closed. // session closed.
val feedback = GamepadFeedback(handle, router).also { it.start() } 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 { onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it 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 feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
@@ -219,14 +219,31 @@ object Gamepad {
), ),
) )
// HAT → dpad button transitions (track previous, emit only the deltas). // HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)) // 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 (hx != hatX) {
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false) 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) if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true)
hatX = hx hatX = hx
} }
val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y))
if (hy != hatY) { if (hy != hatY) {
if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false) 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) if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
@@ -64,10 +64,16 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
private var rumbleThread: Thread? = null private var rumbleThread: Thread? = null
private var hidoutThread: Thread? = null private var hidoutThread: Thread? = null
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble
// rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the // thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes
// main thread AFTER joining those threads (join establishes the happens-before), so plain maps are // from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining
// race-free. A null value caches "this controller has no vibrator / no controllable lights". // 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<Int, RumbleBind?>() private val rumbleBinds = HashMap<Int, RumbleBind?>()
private val lightBinds = HashMap<Int, LightBind?>() private val lightBinds = HashMap<Int, LightBind?>()
@@ -122,13 +128,35 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
rumbleThread = null rumbleThread = null
hidoutThread = null hidoutThread = null
// Threads are dead — drop any held rumble and close every lights session. // Threads are dead — drop any held rumble and close every lights session.
for (b in rumbleBinds.values) b?.let { synchronized(bindsLock) {
runCatching { it.vm?.cancel() } for (b in rumbleBinds.values) b?.let {
runCatching { it.legacy?.cancel() } 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 ---- // ---- 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. */ /** 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? { private fun rumbleBindFor(pad: Int): RumbleBind? {
val dev = router?.deviceForPad(pad) ?: return null val dev = router?.deviceForPad(pad) ?: return null
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] synchronized(bindsLock) {
val bind = bindRumble(dev) if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
rumbleBinds[dev.id] = bind val bind = bindRumble(dev)
return bind rumbleBinds[dev.id] = bind
return bind
}
} }
private fun bindRumble(dev: InputDevice): RumbleBind? { 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 // 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 = private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
VibrationEffect.createOneShot(durationMs, amp) VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
// ---- HID output ---- // ---- HID output ----
@@ -268,10 +302,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
private fun lightBindFor(pad: Int): LightBind? { private fun lightBindFor(pad: Int): LightBind? {
if (Build.VERSION.SDK_INT < 33) return null if (Build.VERSION.SDK_INT < 33) return null
val dev = router?.deviceForPad(pad) ?: return null val dev = router?.deviceForPad(pad) ?: return null
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] synchronized(bindsLock) {
val bind = bindLights(dev) if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
lightBinds[dev.id] = bind val bind = bindLights(dev)
return bind lightBinds[dev.id] = bind
return bind
}
} }
private fun bindLights(dev: InputDevice): LightBind? { private fun bindLights(dev: InputDevice): LightBind? {
@@ -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]. */ /** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
private val slots = ConcurrentHashMap<Int, Slot>() private val slots = ConcurrentHashMap<Int, Slot>()
/**
* 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 inputManager = context.getSystemService(InputManager::class.java)
private val listener = object : InputManager.InputDeviceListener { private val listener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) { override fun onInputDeviceAdded(deviceId: Int) {
@@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
} }
init { 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 // 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. // will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
for (id in InputDevice.getDeviceIds()) { 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_* * 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 * 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 * slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
* (mirrors the Linux client's escape chord: any one controller can leave). * [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 { fun onButton(event: KeyEvent, bit: Int) {
val slot = slotFor(event.device) ?: return false val slot = slotFor(event.device) ?: return
when (event.action) { when (event.action) {
KeyEvent.ACTION_DOWN -> { KeyEvent.ACTION_DOWN -> {
// repeatCount guard: don't re-send a held button as auto-repeat. // repeatCount guard: don't re-send a held button as auto-repeat.
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit slot.held = slot.held or bit
if (slot.held and EXIT_CHORD == EXIT_CHORD) { // Full chord now held on this pad → start the hold countdown (idempotent while held).
slot.held = 0 if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
return true
}
} }
KeyEvent.ACTION_UP -> { KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv() 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() { fun release() {
inputManager?.unregisterInputDeviceListener(listener) 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. // Snapshot the ids first — closeSlot mutates the map.
for (id in slots.keys.toList()) closeSlot(id) 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 val slot = slots.remove(deviceId) ?: return
releaseHeld(slot) releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index) 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). */ /** 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). */ /** 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 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
} }
} }