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()
+70 -29
View File
@@ -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,
@@ -499,9 +499,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> 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,
@@ -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');
@@ -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<TextField>(find.byType(TextField)).readOnly, isFalse);
});
testWidgets('TV discovery keeps initial URL focus and D-pad reaches discovered servers', (tester) async {
+116 -28
View File
@@ -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<TextField>(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<TextField>(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<TextField>(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 = <bool>[];
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;