From 98b97d7d76e2e99de1b45c7aee7054f7e44c24c0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 23 Jul 2026 17:06:52 +0200 Subject: [PATCH] fix(android/input): stop mouse back/forward leaking to nav + IME popping on hardware typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Android TV M&K regressions from the c90343c2 overhaul, both user-reported: 1. Mouse back/forward buttons doubled as Android navigation while streaming. The BUTTON_BACK/FORWARD motion edges already go over the wire as X1/X2, but Android's input reader ALSO synthesizes KEYCODE_BACK/FORWARD key events from those buttons unconditionally (stamped SOURCE_MOUSE, no FLAG_FALLBACK) — and the guard only swallowed the FLAG_FALLBACK variant, so the duplicate reached the BackHandler and yanked the user out of the stream. Swallow mouse-sourced BACK/FORWARD too; a remote/keyboard BACK is never mouse-sourced and still exits. 2. The soft keyboard popped up on physical-keyboard typing. KeyCaptureView (the always-focused IME/capture anchor) unconditionally claimed to be a text editor with a live editable InputConnection, so the TV IME counted input as active and showed its UI the moment hardware keys arrived. Gate the editor identity on imeShown (the user actually summoning the keyboard via gesture / remote toggle), with restartInput on both edges so the gate is re-read, and an onKeyPreIme BACK hook so an IME self-dismissal can't leave the gate stale. Co-Authored-By: Claude Fable 5 --- .../kotlin/io/unom/punktfunk/MainActivity.kt | 18 ++++++++---- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 29 +++++++++++++++++-- 2 files changed, 40 insertions(+), 7 deletions(-) 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 7650d9fd..8d12f315 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 @@ -355,11 +355,19 @@ class MainActivity : ComponentActivity() { return true } when (event.keyCode) { - // A mouse back/forward button whose BUTTON_* press went unconsumed makes the - // framework synthesize a FALLBACK BACK — the button already went over the wire - // as X1/X2, and it must never yank the user out of the stream. - KeyEvent.KEYCODE_BACK -> - if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return true + // 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. + KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD -> + if (event.isFromSource(InputDevice.SOURCE_MOUSE) || + event.flags and KeyEvent.FLAG_FALLBACK != 0 + ) { + return true + } // Leave these to the system even while streaming. // (BACK above → BackHandler leaves the stream.) KeyEvent.KEYCODE_VOLUME_UP, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 4e21f75f..72d93977 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -13,6 +13,7 @@ import android.net.wifi.WifiManager import android.os.Build import android.text.InputType import android.util.Log +import android.view.KeyEvent import android.view.SurfaceHolder import android.view.SurfaceView import android.view.View @@ -559,9 +560,15 @@ private class KeyCaptureView(context: Context) : View(context) { var imeShown = false private set - override fun onCheckIsTextEditor(): Boolean = true + override fun onCheckIsTextEditor(): Boolean = imeShown - override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + // Only an editor while the user has SUMMONED the keyboard (gesture / remote toggle). + // This view holds focus for the whole stream (it's the capture anchor), and with an + // always-live editable connection the IME counts input as active on it — TV IMEs then + // pop their UI the moment a PHYSICAL keyboard key arrives. With no connection, hardware + // typing stays on the raw dispatchKeyEvent → Keymap → wire path and no keyboard appears. + if (!imeShown) return null outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_ENTER_ACTION return if (textHandle != 0L) { @@ -580,11 +587,29 @@ private class KeyCaptureView(context: Context) : View(context) { imeShown = show if (show) { requestFocus() + // The view may already be focused from a null-connection state — restart so the + // framework re-queries onCreateInputConnection with the gate now open. + imm.restartInput(this) imm.showSoftInput(this, 0) } else { imm.hideSoftInputFromWindow(windowToken, 0) + imm.restartInput(this) // gate closed — drop the editable connection } } + + /** + * BACK while the summoned keyboard is up: the IME consumes it pre-IME to dismiss itself, so + * [setImeVisible] never hears about it — sync the gate here or a stale `imeShown` leaves the + * editable connection live and physical typing re-pops the keyboard. + */ + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && imeShown && event.action == KeyEvent.ACTION_UP) { + imeShown = false + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager) + ?.restartInput(this) + } + return super.onKeyPreIme(keyCode, event) + } } /**