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:
@@ -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