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:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user