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 8d12f315..b8867d26 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 @@ -339,6 +339,17 @@ 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)) { @@ -355,13 +366,12 @@ class MainActivity : ComponentActivity() { return true } when (event.keyCode) { - // A mouse's back/forward buttons already go over the wire as X1/X2 via their - // BUTTON_* motion edges — but Android ALSO delivers them as key events: the input - // reader synthesizes KEYCODE_BACK/FORWARD (stamped SOURCE_MOUSE) unconditionally, - // and a view-level FALLBACK BACK appears when the BUTTON_* press goes unconsumed. - // Swallow every such duplicate or it 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] 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. KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD -> if (event.isFromSource(InputDevice.SOURCE_MOUSE) || event.flags and KeyEvent.FLAG_FALLBACK != 0 @@ -431,6 +441,34 @@ class MainActivity : ComponentActivity() { return super.dispatchKeyEvent(event) } + /** + * `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is + * anything else — including a remote's or keyboard's BACK, which must keep exiting the stream. + * + * A mouse that carries its side buttons on the HID consumer page (AC Back / AC Forward) reaches + * us only as `KEYCODE_BACK`/`KEYCODE_FORWARD`, with no `BUTTON_BACK`/`BUTTON_FORWARD` motion + * edge behind it — on those, the motion path alone leaves the side buttons dead. The event may + * even be stamped SOURCE_KEYBOARD rather than SOURCE_MOUSE, because the consumer-page collection + * is a separate sub-device, so the DEVICE is what we ask: it has to be able to be a mouse. + * + * A D-pad-capable device is excluded even when it also reports a pointer: that is an air-mouse + * remote, whose BACK is the couch user's way out of the stream and must stay navigation. + * FLAG_FALLBACK events are excluded too — those are a duplicate the framework raises after an + * unconsumed BUTTON_* press, i.e. one the motion path already forwarded. + */ + private fun mouseSideButton(event: KeyEvent): Boolean? { + val back = when (event.keyCode) { + KeyEvent.KEYCODE_BACK -> true + KeyEvent.KEYCODE_FORWARD -> false + else -> return null + } + if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return null + val device = event.device ?: return null + if (!device.supportsSource(InputDevice.SOURCE_MOUSE)) return null + if (device.supportsSource(InputDevice.SOURCE_DPAD)) return null + return back + } + /** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */ private var lastNavDir = 0 diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MouseInput.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MouseInput.kt index 425bb4e8..26fd5a4a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MouseInput.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MouseInput.kt @@ -180,6 +180,22 @@ class MouseForwarder( } } + /** + * A mouse side button that arrived as a KEY event rather than a BUTTON_* motion edge. + * + * Not every mouse reports its side buttons the same way. One that puts them on the HID button + * page (BTN_SIDE/BTN_EXTRA) gets `BUTTON_BACK`/`BUTTON_FORWARD` in the motion button state and + * lands in [button]. One that puts them on the consumer page (AC Back / AC Forward — common on + * Bluetooth mice, and the shape Android TV boxes tend to see) produces ONLY synthesized + * `KEYCODE_BACK`/`KEYCODE_FORWARD` key events, so [button] never fires and the side buttons are + * dead on the wire. This is the key-shaped entry point for those. + * + * Devices that report BOTH send the key first and the motion edge second (that is the order the + * input reader synthesizes them in), so both paths funnel into the same held-set and the + * add/remove guard collapses the pair into a single wire press. + */ + fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down) + private fun button(actionButton: Int, down: Boolean) { val b = when (actionButton) { MotionEvent.BUTTON_PRIMARY -> 1 @@ -189,9 +205,15 @@ class MouseForwarder( MotionEvent.BUTTON_FORWARD -> 5 else -> return } + press(b, down) + } + + private fun press(b: Int, down: Boolean) { if (down) { - heldButtons.add(b) - NativeBridge.nativeSendPointerButton(handle, b, true) + // add() is false when the button is already held — the second delivery of a button + // this device reports on two paths at once. Sending the down again would double-press + // it on the host. + if (heldButtons.add(b)) NativeBridge.nativeSendPointerButton(handle, b, true) } else if (heldButtons.remove(b)) { // Only release what we pressed — drops the release of a swallowed engaging click // and anything that raced a capture transition.