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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Int, RumbleBind?>()
|
||||
private val lightBinds = HashMap<Int, LightBind?>()
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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<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 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user