fix(tv): restore the native Android IME for single-line text input

Android TV returns to the platform keyboard for single-line fields; the
Flutter overlay stays for multiline and explicit call sites. The bugs
that forced the overlay (#1051, #1079) were an engine show/bind ordering
race, now repaired at the app level:

- MainActivity retries a soft-input show the engine dropped while the
  FlutterView was not yet served (flutter/flutter#177360), rebinds the
  IME key session once at first show, and consumes leaked D-pad keys
  while the keyboard is visible (bounded restartInput budget) so focus
  cannot wander behind a stuck keyboard.
- The platform text-input hint is activation-based, so gamepad pause and
  the pre-IME D-pad intercept track a live session instead of mere field
  focus.
- While a session is live with the keyboard away, Back closes it and is
  consumed once, Select re-raises the keyboard, and arrows keep
  caret-aware edge-escape navigation instead of dead-ending.
This commit is contained in:
edde746
2026-08-09 09:56:53 +02:00
parent 9d51f5aadd
commit ce9556db22
6 changed files with 333 additions and 66 deletions
@@ -10,13 +10,18 @@ import android.content.res.Configuration
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import android.util.Rational
import android.view.InputDevice
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
@@ -46,6 +51,20 @@ class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
// Flutter's TextInputPlugin issues showSoftInput before the FlutterView is
// the IMM's served view (the InputConnection restart is deferred to the
// next channel message), so on TV the D-pad-driven first open is dropped
// with "Ignoring showSoftInput() as view ... is not served" and never
// retried (flutter/flutter#177360). These bounded retries re-issue the
// show once the view is served; the restart budget repairs the sibling
// failure mode where the keyboard shows but its key session never bound
// ("Ignoring onBind: cur seq=-1"), leaving Gboard blind to D-pad
// (#1051, #1079).
private const val IME_SHOW_RETRY_LIMIT = 4
private const val IME_SHOW_RETRY_INTERVAL_MS = 300L
private const val IME_LEAK_RESTART_BUDGET = 2
private const val IME_LEAK_RESTART_MIN_INTERVAL_MS = 1000L
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
@@ -80,6 +99,13 @@ class MainActivity : FlutterActivity() {
private var carRestrictions: CarRestrictionsMonitor? = null
private var carRestrictionsChannel: MethodChannel? = null
private var nativeTextInputFocused = false
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
private var imeShowAttempts = 0
private var imeLeakRestartBudget = 0
private var imeRestartedOnShow = false
private var imeWasVisible = false
private var lastImeLeakRestartUptime = 0L
private var imeVisibilityListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null
private var flutterSurfaceReconnectPending = false
@@ -163,6 +189,82 @@ class MainActivity : FlutterActivity() {
return forward
}
private fun inputMethodManager(): InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
private fun flutterView(): View? = findViewById(FLUTTER_VIEW_ID)
// Re-issues a soft-input show that the engine dropped because the
// FlutterView was not yet the IMM's served view when TextInput.show ran
// (flutter/flutter#177360). Flutter never retries on its own — its Dart
// side believes the keyboard is already up — so without this the first
// D-pad-driven open on TV can silently do nothing.
private val imeShowRetry = object : Runnable {
override fun run() {
if (!nativeTextInputFocused) return
if (isImeVisible()) return
val view = flutterView()
val imm = inputMethodManager()
if (view != null && imm.isActive(view)) {
logTextInputDiag { "imeShowRetry re-showing attempt=$imeShowAttempts ${describeImeState()}" }
imm.showSoftInput(view, 0)
} else {
logTextInputDiag { "imeShowRetry waiting attempt=$imeShowAttempts served=${view != null && imm.isActive(view)}" }
}
imeShowAttempts++
if (imeShowAttempts < IME_SHOW_RETRY_LIMIT) {
imeRecoveryHandler.postDelayed(this, IME_SHOW_RETRY_INTERVAL_MS)
}
}
}
private fun startNativeTextInputSession() {
imeShowAttempts = 0
imeLeakRestartBudget = IME_LEAK_RESTART_BUDGET
imeRestartedOnShow = false
imeRecoveryHandler.removeCallbacks(imeShowRetry)
imeRecoveryHandler.postDelayed(imeShowRetry, IME_SHOW_RETRY_INTERVAL_MS)
}
private fun endNativeTextInputSession() {
imeRecoveryHandler.removeCallbacks(imeShowRetry)
}
private fun restartNativeTextInput(reason: String) {
val view = flutterView() ?: return
logTextInputDiag { "restartInput reason=$reason ${describeImeState()}" }
inputMethodManager().restartInput(view)
}
// A visible IME owns D-pad navigation: a healthy Gboard consumes these keys
// at the ImeInputStage, before the app. One arriving here therefore means
// the IME's key session never bound ("Ignoring onBind: cur seq=-1") — the
// Chromecast/Google TV failure of #1051/#1079. Repair by rebinding, and eat
// the press so Flutter focus cannot wander behind the stuck keyboard. The
// bounded budget guarantees keys flow again (and Flutter can close the
// session) if rebinding cannot heal the device.
private fun consumeLeakedImeNavigationKey(event: KeyEvent): Boolean {
if (!nativeTextInputFocused || imeLeakRestartBudget <= 0) return false
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER -> Unit
else -> return false
}
if (!isImeVisible()) return false
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val now = SystemClock.uptimeMillis()
if (now - lastImeLeakRestartUptime >= IME_LEAK_RESTART_MIN_INTERVAL_MS) {
lastImeLeakRestartUptime = now
imeLeakRestartBudget--
restartNativeTextInput("leaked-dpad-while-ime-visible")
}
}
logTextInputDiag { "consuming leaked IME key ${describeKeyEvent(event)} budget=$imeLeakRestartBudget" }
return true
}
private fun getAndroidTvDetection(): Map<String, Any> {
val pm = packageManager
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
@@ -433,6 +535,24 @@ class MainActivity : FlutterActivity() {
)
)
// Watch IME visibility so a fresh session can be rebound the moment the
// keyboard first shows: on Chromecast-class devices the initial bind can
// land against a stale sequence, leaving the IME without a key session
// (D-pad dead, #1051/#1079). One restartInput at first-show — before the
// user has typed or moved the key highlight — repairs it invisibly.
val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener {
val visible = isImeVisible()
if (visible == imeWasVisible) return@OnGlobalLayoutListener
imeWasVisible = visible
logTextInputDiag { "ime visibility changed visible=$visible ${describeImeState()}" }
if (visible && nativeTextInputFocused && !imeRestartedOnShow) {
imeRestartedOnShow = true
restartNativeTextInput("first-show-rebind")
}
}
window.decorView.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener)
imeVisibilityListener = visibilityListener
// Handle Watch Next deep link from initial launch
handleWatchNextIntent(intent)
}
@@ -447,6 +567,9 @@ class MainActivity : FlutterActivity() {
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" }
}
// Reaching the activity means the ImeInputStage already declined this
// key, so consumption below cannot starve a healthy IME.
if (consumeLeakedImeNavigationKey(event)) return true
val handled = super.dispatchKeyEvent(event)
if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag {
@@ -464,6 +587,9 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
externalPlayerChannel.dispose()
endNativeTextInputSession()
imeVisibilityListener?.let { window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(it) }
imeVisibilityListener = null
carRestrictions?.release()
carRestrictions = null
carRestrictionsChannel = null
@@ -666,6 +792,11 @@ class MainActivity : FlutterActivity() {
logTextInputDiag {
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
}
if (nativeTextInputFocused && !oldValue) {
startNativeTextInputSession()
} else if (!nativeTextInputFocused && oldValue) {
endNativeTextInputSession()
}
result.success(null)
}
else -> result.notImplemented()