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:
2026-08-14 20:10:04 +00:00
3 changed files with 144 additions and 19 deletions
@@ -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))
}
}