Merge pull request 'A pad whose Select is KEYCODE_BACK quit the session on one press' (#235) from worktree-shield-select-back-quit into main
Reviewed-on: #235
This commit is contained in:
@@ -526,10 +526,25 @@ class MainActivity : ComponentActivity() {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val handle = streamHandle
|
||||
if (handle != 0L) {
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the gamepad and remote-pointer hooks so neither can claim them as
|
||||
// its own BACK. See [mouseSideButton] for how a mouse's BACK is told from a pad's or
|
||||
// a remote's; it answers null for every device that cannot be a mouse, so asking it
|
||||
// first re-routes nothing else.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Gamepad buttons (incl. DPAD only when truly from a gamepad — else KEYCODE_DPAD_* are
|
||||
// keyboard arrows and belong to the VK path below).
|
||||
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
|
||||
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
|
||||
// why this asks it rather than `buttonBit`).
|
||||
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
val bit = Gamepad.buttonBit(event.keyCode)
|
||||
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
|
||||
if (bit != 0) {
|
||||
// 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
|
||||
@@ -540,17 +555,6 @@ class MainActivity : ComponentActivity() {
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
|
||||
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
@@ -567,12 +571,13 @@ class MainActivity : ComponentActivity() {
|
||||
return true
|
||||
}
|
||||
when (event.keyCode) {
|
||||
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
|
||||
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
|
||||
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
|
||||
// either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
|
||||
// to the BackHandler and exits.
|
||||
// Whatever [mouseSideButton] and the pad branch didn't claim. A view-level FALLBACK
|
||||
// BACK appears when a BUTTON_* press goes unconsumed, and an air-mouse remote stamps
|
||||
// its own BACK SOURCE_MOUSE; both are duplicates of something already handled, and
|
||||
// letting either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced and never gamepad-sourced,
|
||||
// so it still falls through to the BackHandler and exits — which for a device with
|
||||
// no pad on it is the documented way out.
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
||||
|
||||
@@ -230,6 +230,46 @@ object Gamepad {
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The BTN_* bit for one key event from a SOURCE_GAMEPAD device — [buttonBit] plus the
|
||||
* Select-family button of every pad that carries no `BUTTON_SELECT` scancode at all.
|
||||
*
|
||||
* Plenty of controllers deliver that button as the plain `KEYCODE_BACK` a remote's Back uses,
|
||||
* with no `BUTTON_SELECT` behind it: it is the Android-TV shape, where every input device is
|
||||
* expected to offer Back, and a pad reaches it whether the vendor prints "Back" on the button
|
||||
* (NVIDIA's SHIELD controller) or "Select"/"View" (most pads in an Android mode). Which one is
|
||||
* on the couch cannot be told from here, and does not need to be — the keycode is what routes.
|
||||
*
|
||||
* Read through [buttonBit] alone that button mapped to nothing, so it fell out of the
|
||||
* streaming branch unconsumed and reached the activity's back stack, which is the
|
||||
* deliberate-quit exit: ONE press of Select dropped the session and the host logged a client
|
||||
* quit. `KEYCODE_BACK` is in fact the ONLY keycode that can get there from a pad — a mapped
|
||||
* button is consumed here, anything with a VK is consumed on the keycode path, volume/power go
|
||||
* to the system, and a FLAG_FALLBACK BACK is swallowed — which is what identifies this as the
|
||||
* cause of such a report without knowing the hardware.
|
||||
*
|
||||
* It also meant such a pad could not produce [BTN_BACK] at all, so every shortcut built on
|
||||
* Select — the emergency exit chord this client's own start banner advertises, the mic mute,
|
||||
* the stats tier — was unreachable on exactly the devices whose users have no keyboard.
|
||||
*
|
||||
* A pad that DOES carry `BUTTON_SELECT` is unaffected in both directions: it never had the
|
||||
* bug, and this changes nothing for it.
|
||||
*
|
||||
* FLAG_FALLBACK events are excluded: those are the synthetic BACK the framework raises after
|
||||
* an unconsumed `BUTTON_*` press (a pad reporting L2/R2 as keys, say), not a button anyone
|
||||
* touched, and forwarding one would put a phantom Select on the wire. `MainActivity` drops
|
||||
* them on the keycode path for the same reason.
|
||||
*
|
||||
* Callers must gate on `SOURCE_GAMEPAD` before asking, exactly as [buttonBit]'s `KEYCODE_DPAD_*`
|
||||
* rows require: a remote's or keyboard's BACK shares this keycode and has to keep leaving the
|
||||
* stream — for a device with no pad on it, Back IS the documented way out.
|
||||
*/
|
||||
fun padButtonBit(keyCode: Int, flags: Int): Int = when {
|
||||
keyCode != KeyEvent.KEYCODE_BACK -> buttonBit(keyCode)
|
||||
flags and KeyEvent.FLAG_FALLBACK != 0 -> 0
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of [Gamepad.padButtonBit] — the streaming branch's gamepad keycode resolution
|
||||
* (`KeyEvent`'s keycode/flag constants are compile-time-inlined ints, so no Android runtime is
|
||||
* involved). Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*
|
||||
* The regression it pins is a field report: one press of Select disconnected the session. Plenty
|
||||
* of pads deliver that button as the plain `KEYCODE_BACK` a remote uses, with no `BUTTON_SELECT`
|
||||
* scancode behind it — so it mapped to nothing, fell out of the gamepad branch unconsumed, and
|
||||
* reached the activity back stack, which is the deliberate-quit exit. The same gap made
|
||||
* [Gamepad.BTN_BACK] unreachable on those pads, and with it every shortcut built on Select: the
|
||||
* exit chord `StreamScreen`'s own start banner advertises, the mic mute, the stats tier.
|
||||
*
|
||||
* Which controller the report came from is not knowable from the logs and does not matter:
|
||||
* `KEYCODE_BACK` is the only keycode that reaches the back stack from a SOURCE_GAMEPAD device, so
|
||||
* a one-press quit identifies the button's keycode on its own.
|
||||
*/
|
||||
class PadButtonBitTest {
|
||||
|
||||
/** The report: Select on an Android-TV pad arrives as BACK and must be the Select bit. */
|
||||
@Test
|
||||
fun `a pad's BACK is its Select button`() {
|
||||
assertEquals(Gamepad.BTN_BACK, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0))
|
||||
// Same bit either spelling reaches us by — a pad that DOES carry BUTTON_SELECT is unchanged.
|
||||
assertEquals(
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_SELECT, 0),
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* With Select mapped, the three Select chords are reachable on a pad that has only a BACK
|
||||
* keycode — which is the whole point of the mapping, not a side effect of it. Held-state
|
||||
* assembly is [GamepadRouter]'s (see `GamepadChordTest`); what is pinned here is that the
|
||||
* bits a SHIELD can actually produce cover each chord.
|
||||
*/
|
||||
@Test
|
||||
fun `the Select chords are reachable from a BACK-only pad`() {
|
||||
val select = Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, 0)
|
||||
val start = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_START, 0)
|
||||
val l1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_L1, 0)
|
||||
val r1 = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_R1, 0)
|
||||
val x = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_X, 0)
|
||||
val y = Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_Y, 0)
|
||||
assertEquals(GamepadRouter.EXIT_CHORD, select or start or l1 or r1)
|
||||
assertEquals(GamepadRouter.STATS_CHORD, select or x)
|
||||
assertEquals(GamepadRouter.MIC_CHORD, select or y)
|
||||
}
|
||||
|
||||
/**
|
||||
* The synthetic BACK the framework raises after an unconsumed `BUTTON_*` press is not a button
|
||||
* anyone touched — forwarding it would put a phantom Select on the wire, and one of those
|
||||
* landing while Start + L1 + R1 were held would complete the exit chord out of nowhere.
|
||||
*/
|
||||
@Test
|
||||
fun `a fallback BACK is not a button press`() {
|
||||
assertEquals(0, Gamepad.padButtonBit(KeyEvent.KEYCODE_BACK, KeyEvent.FLAG_FALLBACK))
|
||||
// Only BACK is filtered on the flag; a real button keeps its bit whatever rides alongside.
|
||||
assertEquals(
|
||||
Gamepad.BTN_A,
|
||||
Gamepad.padButtonBit(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.FLAG_FALLBACK),
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything else is [Gamepad.buttonBit] verbatim — BACK is the only row this adds. */
|
||||
@Test
|
||||
fun `every other keycode is unchanged`() {
|
||||
for (code in 0..0x400) {
|
||||
if (code == KeyEvent.KEYCODE_BACK) continue
|
||||
assertEquals(Gamepad.buttonBit(code), Gamepad.padButtonBit(code, 0))
|
||||
}
|
||||
// And BACK is genuinely a new row, not one buttonBit already had.
|
||||
assertEquals(0, Gamepad.buttonBit(KeyEvent.KEYCODE_BACK))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user