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 a4866715..72000eca 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 @@ -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 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 08b94137..d740c4a8 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 @@ -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 diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadButtonBitTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadButtonBitTest.kt new file mode 100644 index 00000000..be84fe01 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadButtonBitTest.kt @@ -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)) + } +}