diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 8ba7d108..adb504e8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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 { 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() diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 1b4b951e..a54fbca7 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -12,8 +12,9 @@ import 'key_event_utils.dart'; import 'owned_focus_node_binding.dart'; enum TvTextInputPresentation { - /// Use the native platform keyboard for single-line Apple TV input and the - /// Flutter overlay on other TVs or for multiline input. + /// Use the native platform keyboard for single-line input on every TV and + /// the Flutter overlay for multiline input, whose newline/caret handling + /// the TV IMEs do not cover well. automatic, /// Always use the platform text input implementation. @@ -26,8 +27,7 @@ enum TvTextInputPresentation { bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) { if (!PlatformDetector.isTV()) return false; return switch (presentation) { - TvTextInputPresentation.automatic => - !PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines), + TvTextInputPresentation.automatic => _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines), TvTextInputPresentation.platform => false, TvTextInputPresentation.flutterOverlay => true, }; @@ -69,8 +69,8 @@ enum TvTextInputAutoOpenBehavior { /// This is the one documented exception to the `automatic` rule that a field's /// first focus opens text input — the URL field's first focus is the screen's /// own `autofocus`, not the user arriving. Apple TV therefore waits for an -/// explicit Select; the in-app overlay is cheap enough to open on a deliberate -/// return. +/// explicit Select; Android's docked IME is cheap enough to open on a +/// deliberate return. TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen => PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus; @@ -310,19 +310,23 @@ bool _shouldPassNativeTvKeyToPlatform({ required bool enabled, required KeyEvent event, }) { - if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) { + if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isAppleTV()) { if (TextInputDiagnostics.enabled) { _logTvTextInput( 'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled ' 'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive ' - 'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})', + 'isAppleTV=${PlatformDetector.isAppleTV()} key=(${_describeTextInputKey(event)})', ); } return false; } - // Android TV provides its own IME. Remote keys must reach the platform so - // users can move around that keyboard instead of escaping the app field. + // tvOS only: the custom engine routes remote keys through Flutter even + // while UIKit text input is live, so they must be skipped back to the + // platform to drive the system keyboard. Android needs no such pass — the + // IME sees hardware keys *before* the app (ImeInputStage), so a navigation + // key arriving here was already declined by the IME and must keep its + // local caret/traversal semantics (see the host's Android branch). // Some remotes (Chromecast) are reported by Flutter as keyboard events, so // native TV navigation cannot rely on deviceType. final key = event.logicalKey; @@ -864,8 +868,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { void _restoreFocusAfterPlatformDismissal() { final node = _installedFocusNode; if (node == null || node.hasFocus || !_nativeTextInputActivated) return; - // Apple TV only: Android TV's IME close keeps its historical semantics, - // and no production field selects the native path there anyway. + // Apple TV only: connectionClosed-driven unfocus is a behavior of the + // custom tvOS engine. Android's IME hide keeps the field focused and the + // connection alive, so there is nothing to restore there. if (!PlatformDetector.isAppleTV()) return; if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return; final scope = node.enclosingScope; @@ -978,12 +983,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { } void _syncNativeTextInputFocus() { - final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard; + // Activation-based, not focus-based: the platform hint pauses the gamepad + // bridge and defers the pre-IME D-pad intercept to the IME, and it arms + // MainActivity's soft-input show-retry/repair session — all of which must + // track a *live* text input session, not a merely focused (read-only + // gated) field. A dismissed keyboard therefore hands D-pad routing back + // to the app immediately. + final focused = + _installedFocusNode?.hasFocus == true && + widget.input.enabled && + widget.input._usesNativeTvKeyboard && + _nativeTextInputActivated; if (TextInputDiagnostics.enabled) { _logTvTextInput( 'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} ' 'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} ' - 'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}', + 'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard} activated=$_nativeTextInputActivated', ); } _setNativeTextInputFocused(focused); @@ -1172,30 +1187,56 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { } var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated; final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent; - if (PlatformDetector.isAppleTV() && - widget.input._usesNativeTvKeyboard && + if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated && event is KeyDownEvent && isRemoteNavigation) { - // Remote navigation events are system-owned while the native keyboard - // is active. Receiving one here proves that UIKit has dismissed the - // keyboard while Flutter focus stayed on the field. Restore the - // read-only gate so this press navigates Flutter instead of reopening - // the input connection. - _suppressNativeTextInputForCurrentFocus = true; - _setNativeTextInputActivated(false); - activateNativeTextInput = true; - if (event.logicalKey.isBackKey) { - // This is the Menu press that dismissed UIKit's keyboard. Consume its - // Flutter continuation so one press cannot also pop the app route. + if (PlatformDetector.isAppleTV()) { + // Remote navigation events are system-owned while the native keyboard + // is active. Receiving one here proves that UIKit has dismissed the + // keyboard while Flutter focus stayed on the field. Restore the + // read-only gate so this press navigates Flutter instead of reopening + // the input connection. + _suppressNativeTextInputForCurrentFocus = true; + _setNativeTextInputActivated(false); + activateNativeTextInput = true; + if (event.logicalKey.isBackKey) { + // This is the Menu press that dismissed UIKit's keyboard. Consume its + // Flutter continuation so one press cannot also pop the app route. + return KeyEventResult.handled; + } + if (event.isTvSelectEvent) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _activateNativeTextInput(); + }); + return KeyEventResult.handled; + } + } else if (event.logicalKey.isBackKey) { + // Android: a healthy IME consumes Back to dismiss itself before the + // app ever sees it. One arriving here means the keyboard is already + // gone (or its key session is broken and MainActivity's repair budget + // ran out): close the session and consume the press so it cannot also + // pop the route underneath. + _suppressNativeTextInputForCurrentFocus = true; + _setNativeTextInputActivated(false); return KeyEventResult.handled; - } - if (event.isTvSelectEvent) { + } else if (event.isTvSelectEvent) { + // Android: Select on a field whose keyboard was dismissed re-raises + // it (EditText parity). Toggle the connection so the engine issues a + // fresh TextInput.show; MainActivity's show-retry covers the + // served-view race (#1051/#1079). + _suppressNativeTextInputForCurrentFocus = true; + _setNativeTextInputActivated(false); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _activateNativeTextInput(); }); return KeyEventResult.handled; } + // Android arrows fall through deliberately: a healthy visible IME + // consumes them before the app, and leaked ones are repaired and eaten + // by MainActivity — so an arrow reaching this handler is real caret or + // traversal input (BT keyboards included) and keeps the caret-aware + // edge-escape semantics below. } return widget.input._handleKey( context, diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index bad0f8e2..aa8325a0 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -499,9 +499,10 @@ class _AddJellyfinScreenState extends State with AsyncFormSta FocusableTextFormField( controller: _urlController, focusNode: _urlFocus, - tvTextInputPresentation: PlatformDetector.isAppleTV() - ? TvTextInputPresentation.platform - : TvTextInputPresentation.automatic, + // Native on every TV: `automatic` would route this wrap-to-4-lines + // field to the Flutter overlay, but it is logically single-line URL + // input and the platform IME handles it (#1051, #1079). + tvTextInputPresentation: TvTextInputPresentation.platform, autofocus: true, tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen, keyboardType: TextInputType.url, diff --git a/test/screens/profile/add_local_profile_screen_test.dart b/test/screens/profile/add_local_profile_screen_test.dart index 6d7ee2c7..06914a4c 100644 --- a/test/screens/profile/add_local_profile_screen_test.dart +++ b/test/screens/profile/add_local_profile_screen_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/screens/profile/add_local_profile_screen.dart'; @@ -63,7 +62,7 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel'); }); - testWidgets('Android TV virtual keyboard done leaves profile name input', (tester) async { + testWidgets('Android TV native keyboard done leaves profile name input', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); TvDetectionService.setForceTVSync(true); @@ -75,9 +74,12 @@ void main() { ); await tester.pumpAndSettle(); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); + // The native IME opens in place: focus stays on the field, no overlay. + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Name'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); - await tester.tap(find.byIcon(Symbols.check_rounded)); + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin'); diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 30dc0de7..88393261 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -389,8 +389,12 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pumpAndSettle(); - expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + // A deliberate return to the field opens the native IME (afterFirstFocus): + // the field becomes editable in place — no Flutter overlay, focus stays on + // the field itself. + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); }); testWidgets('TV discovery keeps initial URL focus and D-pad reaches discovered servers', (tester) async { diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 7e787923..87c3e6c6 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -823,11 +823,10 @@ void main() { expect(find.byType(Dialog), findsNothing); }); - testWidgets('Android TV focus opens the TV virtual keyboard', (tester) async { + testWidgets('Android TV automatic single-line input uses the platform field', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); TvDetectionService.setForceTVSync(true); - await _setTvSurfaceSize(tester); final controller = TextEditingController(); final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); addTearDown(controller.dispose); @@ -844,7 +843,10 @@ void main() { fieldFocusNode.requestFocus(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + // `automatic` opens the docked native IME on focus: the read-only + // activation gate lifts and no Flutter overlay may appear. + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); }); testWidgets('Android TV after-first-focus skips initial auto-open and opens on refocus', (tester) async { @@ -867,6 +869,7 @@ void main() { FocusableTextFormField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, ), Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)), @@ -909,6 +912,7 @@ void main() { body: FocusableTextFormField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, ), ), @@ -927,7 +931,7 @@ void main() { expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); }); - testWidgets('Android TV remote keys are passed to native text input', (tester) async { + testWidgets('Android TV dismissed-keyboard remote keys navigate, reopen, and consume back', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); TvDetectionService.setForceTVSync(true); @@ -960,14 +964,37 @@ void main() { ), ); + // `automatic` auto-open activates the native session on focus. fieldFocusNode.requestFocus(); await tester.pump(); + await tester.pump(); final handler = fieldFocusNode.onKeyEvent!; - final downResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown)); - final selectResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select)); + // Remote keys reach Flutter only when the IME is not consuming them + // (keyboard dismissed, or a broken key session already repaired and eaten + // by MainActivity). Back closes the session and is consumed once so the + // same press cannot also pop the route underneath. final backResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack)); - final keyboardDownResult = handler(fieldFocusNode, _keyboardDpadKey(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(backResult, KeyEventResult.handled); + expect(backs, 0); + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + + // Session closed: the next back reaches the field's own onBack. + final secondBackResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack)); + await tester.pump(); + expect(secondBackResult, KeyEventResult.handled); + expect(backs, 1); + + // Select re-raises the keyboard (activation), not onSelect. + final selectResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select)); + await tester.pump(); + expect(selectResult, KeyEventResult.handled); + expect(selects, 0); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); + + // Chromecast remotes report Select with a keyboard deviceType; while the + // session is live it must still read as a reopen request, not onSelect. final synthesizedSelectResult = handler( fieldFocusNode, const KeyDownEvent( @@ -977,22 +1004,82 @@ void main() { deviceType: ui.KeyEventDeviceType.keyboard, ), ); - final keyboardBackResult = handler(fieldFocusNode, _keyboardDpadKey(LogicalKeyboardKey.goBack)); await tester.pump(); - - expect(downResult, KeyEventResult.skipRemainingHandlers); - expect(selectResult, KeyEventResult.skipRemainingHandlers); - expect(backResult, KeyEventResult.skipRemainingHandlers); - expect(keyboardDownResult, KeyEventResult.skipRemainingHandlers); - expect(synthesizedSelectResult, KeyEventResult.skipRemainingHandlers); - expect(keyboardBackResult, KeyEventResult.skipRemainingHandlers); - expect(fieldFocusNode.hasPrimaryFocus, isTrue); - expect(nextFocusNode.hasFocus, isFalse); + await tester.pump(); + expect(synthesizedSelectResult, KeyEventResult.handled); expect(selects, 0); - expect(backs, 0); + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); + + // Down while the session is live navigates instead of dead-ending behind + // a keyboard that is not there (#1079's trap). + final downResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(downResult, KeyEventResult.handled); + expect(nextFocusNode.hasPrimaryFocus, isTrue); expect(find.byType(Dialog), findsNothing); }); + testWidgets('Android TV platform focus hint tracks activation, not focus', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + const channel = MethodChannel('com.plezy/text_input'); + final sentStates = []; + GamepadService.debugNativeTextInputFocusHandler = (_) async {}; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'setNativeTextInputFocused') sentStates.add(call.arguments as bool); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null), + ); + + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); + final otherFocusNode = FocusNode(debugLabel: 'other'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + addTearDown(otherFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextFormField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.platform, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, + ), + Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()), + ], + ), + ), + ), + ); + + // First focus is suppressed by afterFirstFocus: no live input session, so + // the platform hint stays silent — MainActivity keeps the pre-IME D-pad + // intercept and the gamepad bridge active for plain navigation. + fieldFocusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(sentStates, isEmpty); + + // An explicit Select opens the session: only now does the platform learn + // about it (arming the soft-input show-retry in MainActivity). + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + await tester.pump(); + expect(sentStates, [true]); + + otherFocusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(sentStates, [true, false]); + }); + testWidgets('Android TV native text input focus is reported to platform', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); @@ -1068,6 +1155,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, onSubmitted: (value) => submitted = value, ), ), @@ -1103,7 +1191,11 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: FocusableTextField(controller: controller, focusNode: fieldFocusNode), + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + ), ), ), ); @@ -1255,6 +1347,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never, ), ), @@ -1338,6 +1431,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never, maxLength: 8, inputFormatters: [ @@ -1384,6 +1478,7 @@ void main() { return FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, textInputAction: TextInputAction.search, onNavigateDown: onNavigateDown, ); @@ -1434,6 +1529,7 @@ void main() { return FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, textInputAction: TextInputAction.search, onSubmitted: onSubmitted, onNavigateDown: onNavigateDown, @@ -1477,6 +1573,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, textInputAction: TextInputAction.search, onEditingComplete: () {}, ), @@ -1557,15 +1654,6 @@ KeyDownEvent _remoteKey(LogicalKeyboardKey key) { ); } -KeyDownEvent _keyboardDpadKey(LogicalKeyboardKey key) { - return KeyDownEvent( - physicalKey: _physicalKeyFor(key), - logicalKey: key, - timeStamp: Duration.zero, - deviceType: ui.KeyEventDeviceType.keyboard, - ); -} - PhysicalKeyboardKey _physicalKeyFor(LogicalKeyboardKey key) { if (key == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown; if (key == LogicalKeyboardKey.goBack) return PhysicalKeyboardKey.escape;