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.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process import android.os.Process
import android.os.SystemClock
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.view.InputDevice import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@@ -46,6 +51,20 @@ class MainActivity : FlutterActivity() {
companion object { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false 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 EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit" private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase" private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
@@ -80,6 +99,13 @@ class MainActivity : FlutterActivity() {
private var carRestrictions: CarRestrictionsMonitor? = null private var carRestrictions: CarRestrictionsMonitor? = null
private var carRestrictionsChannel: MethodChannel? = null private var carRestrictionsChannel: MethodChannel? = null
private var nativeTextInputFocused = false 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 originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null private var flutterTextureView: FlutterTextureView? = null
private var flutterSurfaceReconnectPending = false private var flutterSurfaceReconnectPending = false
@@ -163,6 +189,82 @@ class MainActivity : FlutterActivity() {
return forward 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> { private fun getAndroidTvDetection(): Map<String, Any> {
val pm = packageManager val pm = packageManager
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK 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 // Handle Watch Next deep link from initial launch
handleWatchNextIntent(intent) handleWatchNextIntent(intent)
} }
@@ -447,6 +567,9 @@ class MainActivity : FlutterActivity() {
if (isDpadKeyCode(event.keyCode)) { if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" } 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) val handled = super.dispatchKeyEvent(event)
if (isDpadKeyCode(event.keyCode)) { if (isDpadKeyCode(event.keyCode)) {
logTextInputDiag { logTextInputDiag {
@@ -464,6 +587,9 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() { override fun onDestroy() {
externalPlayerChannel.dispose() externalPlayerChannel.dispose()
endNativeTextInputSession()
imeVisibilityListener?.let { window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(it) }
imeVisibilityListener = null
carRestrictions?.release() carRestrictions?.release()
carRestrictions = null carRestrictions = null
carRestrictionsChannel = null carRestrictionsChannel = null
@@ -666,6 +792,11 @@ class MainActivity : FlutterActivity() {
logTextInputDiag { logTextInputDiag {
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}" "methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
} }
if (nativeTextInputFocused && !oldValue) {
startNativeTextInputSession()
} else if (!nativeTextInputFocused && oldValue) {
endNativeTextInputSession()
}
result.success(null) result.success(null)
} }
else -> result.notImplemented() else -> result.notImplemented()
+70 -29
View File
@@ -12,8 +12,9 @@ import 'key_event_utils.dart';
import 'owned_focus_node_binding.dart'; import 'owned_focus_node_binding.dart';
enum TvTextInputPresentation { enum TvTextInputPresentation {
/// Use the native platform keyboard for single-line Apple TV input and the /// Use the native platform keyboard for single-line input on every TV and
/// Flutter overlay on other TVs or for multiline input. /// the Flutter overlay for multiline input, whose newline/caret handling
/// the TV IMEs do not cover well.
automatic, automatic,
/// Always use the platform text input implementation. /// Always use the platform text input implementation.
@@ -26,8 +27,7 @@ enum TvTextInputPresentation {
bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) { bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) {
if (!PlatformDetector.isTV()) return false; if (!PlatformDetector.isTV()) return false;
return switch (presentation) { return switch (presentation) {
TvTextInputPresentation.automatic => TvTextInputPresentation.automatic => _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
!PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
TvTextInputPresentation.platform => false, TvTextInputPresentation.platform => false,
TvTextInputPresentation.flutterOverlay => true, TvTextInputPresentation.flutterOverlay => true,
}; };
@@ -69,8 +69,8 @@ enum TvTextInputAutoOpenBehavior {
/// This is the one documented exception to the `automatic` rule that a field's /// 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 /// 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 /// 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 /// explicit Select; Android's docked IME is cheap enough to open on a
/// return. /// deliberate return.
TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen => TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen =>
PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus; PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus;
@@ -310,19 +310,23 @@ bool _shouldPassNativeTvKeyToPlatform({
required bool enabled, required bool enabled,
required KeyEvent event, required KeyEvent event,
}) { }) {
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) { if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isAppleTV()) {
if (TextInputDiagnostics.enabled) { if (TextInputDiagnostics.enabled) {
_logTvTextInput( _logTvTextInput(
'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled ' 'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled '
'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive ' 'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive '
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})', 'isAppleTV=${PlatformDetector.isAppleTV()} key=(${_describeTextInputKey(event)})',
); );
} }
return false; return false;
} }
// Android TV provides its own IME. Remote keys must reach the platform so // tvOS only: the custom engine routes remote keys through Flutter even
// users can move around that keyboard instead of escaping the app field. // 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 // Some remotes (Chromecast) are reported by Flutter as keyboard events, so
// native TV navigation cannot rely on deviceType. // native TV navigation cannot rely on deviceType.
final key = event.logicalKey; final key = event.logicalKey;
@@ -864,8 +868,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void _restoreFocusAfterPlatformDismissal() { void _restoreFocusAfterPlatformDismissal() {
final node = _installedFocusNode; final node = _installedFocusNode;
if (node == null || node.hasFocus || !_nativeTextInputActivated) return; if (node == null || node.hasFocus || !_nativeTextInputActivated) return;
// Apple TV only: Android TV's IME close keeps its historical semantics, // Apple TV only: connectionClosed-driven unfocus is a behavior of the
// and no production field selects the native path there anyway. // 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 (!PlatformDetector.isAppleTV()) return;
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return; if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
final scope = node.enclosingScope; final scope = node.enclosingScope;
@@ -978,12 +983,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
} }
void _syncNativeTextInputFocus() { 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) { if (TextInputDiagnostics.enabled) {
_logTvTextInput( _logTvTextInput(
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} ' 'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} ' 'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}', 'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard} activated=$_nativeTextInputActivated',
); );
} }
_setNativeTextInputFocused(focused); _setNativeTextInputFocused(focused);
@@ -1172,30 +1187,56 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
} }
var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated; var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated;
final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent; final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent;
if (PlatformDetector.isAppleTV() && if (widget.input._usesNativeTvKeyboard &&
widget.input._usesNativeTvKeyboard &&
_nativeTextInputActivated && _nativeTextInputActivated &&
event is KeyDownEvent && event is KeyDownEvent &&
isRemoteNavigation) { isRemoteNavigation) {
// Remote navigation events are system-owned while the native keyboard if (PlatformDetector.isAppleTV()) {
// is active. Receiving one here proves that UIKit has dismissed the // Remote navigation events are system-owned while the native keyboard
// keyboard while Flutter focus stayed on the field. Restore the // is active. Receiving one here proves that UIKit has dismissed the
// read-only gate so this press navigates Flutter instead of reopening // keyboard while Flutter focus stayed on the field. Restore the
// the input connection. // read-only gate so this press navigates Flutter instead of reopening
_suppressNativeTextInputForCurrentFocus = true; // the input connection.
_setNativeTextInputActivated(false); _suppressNativeTextInputForCurrentFocus = true;
activateNativeTextInput = true; _setNativeTextInputActivated(false);
if (event.logicalKey.isBackKey) { activateNativeTextInput = true;
// This is the Menu press that dismissed UIKit's keyboard. Consume its if (event.logicalKey.isBackKey) {
// Flutter continuation so one press cannot also pop the app route. // 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; return KeyEventResult.handled;
} } else if (event.isTvSelectEvent) {
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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _activateNativeTextInput(); if (mounted) _activateNativeTextInput();
}); });
return KeyEventResult.handled; 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( return widget.input._handleKey(
context, context,
@@ -499,9 +499,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
FocusableTextFormField( FocusableTextFormField(
controller: _urlController, controller: _urlController,
focusNode: _urlFocus, focusNode: _urlFocus,
tvTextInputPresentation: PlatformDetector.isAppleTV() // Native on every TV: `automatic` would route this wrap-to-4-lines
? TvTextInputPresentation.platform // field to the Flutter overlay, but it is logically single-line URL
: TvTextInputPresentation.automatic, // input and the platform IME handles it (#1051, #1079).
tvTextInputPresentation: TvTextInputPresentation.platform,
autofocus: true, autofocus: true,
tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen, tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/profile/add_local_profile_screen.dart'; import 'package:plezy/screens/profile/add_local_profile_screen.dart';
@@ -63,7 +62,7 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel'); 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); TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true); await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true); TvDetectionService.setForceTVSync(true);
@@ -75,9 +74,12 @@ void main() {
); );
await tester.pumpAndSettle(); 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(); await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin');
@@ -389,8 +389,12 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); // A deliberate return to the field opens the native IME (afterFirstFocus):
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); // 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 { 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); 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); TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true); await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true); TvDetectionService.setForceTVSync(true);
await _setTvSurfaceSize(tester);
final controller = TextEditingController(); final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
addTearDown(controller.dispose); addTearDown(controller.dispose);
@@ -844,7 +843,10 @@ void main() {
fieldFocusNode.requestFocus(); fieldFocusNode.requestFocus();
await tester.pumpAndSettle(); 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 { testWidgets('Android TV after-first-focus skips initial auto-open and opens on refocus', (tester) async {
@@ -867,6 +869,7 @@ void main() {
FocusableTextFormField( FocusableTextFormField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
), ),
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)), Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
@@ -909,6 +912,7 @@ void main() {
body: FocusableTextFormField( body: FocusableTextFormField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
), ),
), ),
@@ -927,7 +931,7 @@ void main() {
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); 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); TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true); await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true); TvDetectionService.setForceTVSync(true);
@@ -960,14 +964,37 @@ void main() {
), ),
); );
// `automatic` auto-open activates the native session on focus.
fieldFocusNode.requestFocus(); fieldFocusNode.requestFocus();
await tester.pump(); await tester.pump();
await tester.pump();
final handler = fieldFocusNode.onKeyEvent!; final handler = fieldFocusNode.onKeyEvent!;
final downResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown)); // Remote keys reach Flutter only when the IME is not consuming them
final selectResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select)); // (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 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( final synthesizedSelectResult = handler(
fieldFocusNode, fieldFocusNode,
const KeyDownEvent( const KeyDownEvent(
@@ -977,22 +1004,82 @@ void main() {
deviceType: ui.KeyEventDeviceType.keyboard, deviceType: ui.KeyEventDeviceType.keyboard,
), ),
); );
final keyboardBackResult = handler(fieldFocusNode, _keyboardDpadKey(LogicalKeyboardKey.goBack));
await tester.pump(); await tester.pump();
await tester.pump();
expect(downResult, KeyEventResult.skipRemainingHandlers); expect(synthesizedSelectResult, KeyEventResult.handled);
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);
expect(selects, 0); 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); 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 { testWidgets('Android TV native text input focus is reported to platform', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true); await TvDetectionService.getInstance(forceTv: true);
@@ -1068,6 +1155,7 @@ void main() {
body: FocusableTextField( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
onSubmitted: (value) => submitted = value, onSubmitted: (value) => submitted = value,
), ),
), ),
@@ -1103,7 +1191,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( 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( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
), ),
), ),
@@ -1338,6 +1431,7 @@ void main() {
body: FocusableTextField( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
maxLength: 8, maxLength: 8,
inputFormatters: [ inputFormatters: [
@@ -1384,6 +1478,7 @@ void main() {
return FocusableTextField( return FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onNavigateDown: onNavigateDown, onNavigateDown: onNavigateDown,
); );
@@ -1434,6 +1529,7 @@ void main() {
return FocusableTextField( return FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onSubmitted: onSubmitted, onSubmitted: onSubmitted,
onNavigateDown: onNavigateDown, onNavigateDown: onNavigateDown,
@@ -1477,6 +1573,7 @@ void main() {
body: FocusableTextField( body: FocusableTextField(
controller: controller, controller: controller,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onEditingComplete: () {}, 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) { PhysicalKeyboardKey _physicalKeyFor(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown; if (key == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown;
if (key == LogicalKeyboardKey.goBack) return PhysicalKeyboardKey.escape; if (key == LogicalKeyboardKey.goBack) return PhysicalKeyboardKey.escape;