fix(tv): preserve native text input routing
This commit is contained in:
@@ -18,6 +18,7 @@ import android.util.Rational
|
||||
import android.view.KeyEvent
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -46,9 +47,11 @@ class MainActivity : FlutterActivity() {
|
||||
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
|
||||
private val THEME_CHANNEL = "com.plezy/theme"
|
||||
private val DEVICE_CHANNEL = "com.plezy/device"
|
||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
|
||||
private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
private var nativeTextInputFocused = false
|
||||
|
||||
// Auto PiP state
|
||||
private var autoPipReady = false
|
||||
@@ -57,6 +60,16 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean
|
||||
|
||||
private fun isImeVisible(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
|
||||
return window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.ime()) == true
|
||||
}
|
||||
|
||||
private fun shouldForwardDpadBeforeIme(): Boolean {
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
return !nativeTextInputFocused && !isImeVisible() && !imm.isAcceptingText
|
||||
}
|
||||
|
||||
private fun getAndroidTvDetection(): Map<String, Any> {
|
||||
val pm = packageManager
|
||||
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
|
||||
@@ -124,8 +137,7 @@ class MainActivity : FlutterActivity() {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
if (!imm.isAcceptingText) {
|
||||
if (shouldForwardDpadBeforeIme()) {
|
||||
super.dispatchKeyEvent(event)
|
||||
return true
|
||||
}
|
||||
@@ -250,6 +262,16 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TEXT_INPUT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setNativeTextInputFocused" -> {
|
||||
nativeTextInputFocused = call.arguments as? Boolean ?: false
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_EXIT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"requestExit" -> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -9,6 +11,40 @@ bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetec
|
||||
|
||||
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
||||
|
||||
class _NativeTvTextInputFocusBridge {
|
||||
static const _channel = MethodChannel('com.plezy/text_input');
|
||||
static final Set<Object> _focusedTokens = <Object>{};
|
||||
static bool _lastSentFocused = false;
|
||||
|
||||
static void setFocused(Object token, bool focused) {
|
||||
if (focused) {
|
||||
_focusedTokens.add(token);
|
||||
} else {
|
||||
_focusedTokens.remove(token);
|
||||
}
|
||||
|
||||
if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) {
|
||||
_lastSentFocused = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final anyFocused = _focusedTokens.isNotEmpty;
|
||||
if (_lastSentFocused == anyFocused) return;
|
||||
_lastSentFocused = anyFocused;
|
||||
unawaited(_sendFocused(anyFocused));
|
||||
}
|
||||
|
||||
static Future<void> _sendFocused(bool focused) async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('setNativeTextInputFocused', focused);
|
||||
} on MissingPluginException {
|
||||
// Tests and non-Android embedders do not register this channel.
|
||||
} on PlatformException {
|
||||
// Focus reporting is a best-effort native routing hint.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleInputKey({
|
||||
required TextEditingController controller,
|
||||
required bool usesTvKeyboard,
|
||||
@@ -528,6 +564,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
FocusNode? _installedFocusNode;
|
||||
FocusOnKeyEventCallback? _previousOnKeyEvent;
|
||||
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
|
||||
late final VoidCallback _focusListener = _syncNativeTextInputFocus;
|
||||
final Object _nativeFocusToken = Object();
|
||||
bool _reportedNativeTextInputFocused = false;
|
||||
|
||||
FocusNode get _effectiveFocusNode =>
|
||||
widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput'));
|
||||
@@ -538,6 +577,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
if (oldWidget.input.focusNode != widget.input.focusNode) {
|
||||
_restoreInstalledHandler();
|
||||
}
|
||||
_syncNativeTextInputFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -547,6 +587,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncNativeTextInputFocus() {
|
||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
|
||||
_setNativeTextInputFocused(focused);
|
||||
}
|
||||
|
||||
void _setNativeTextInputFocused(bool focused) {
|
||||
if (_reportedNativeTextInputFocused == focused) return;
|
||||
_reportedNativeTextInputFocused = focused;
|
||||
_NativeTvTextInputFocusBridge.setFocused(_nativeFocusToken, focused);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
||||
final previous = _previousOnKeyEvent;
|
||||
if (previous != null && !identical(previous, _keyHandler)) {
|
||||
@@ -570,12 +621,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_installedFocusNode = node;
|
||||
_previousOnKeyEvent = node.onKeyEvent;
|
||||
node.onKeyEvent = _keyHandler;
|
||||
node.addListener(_focusListener);
|
||||
}
|
||||
|
||||
void _restoreInstalledHandler() {
|
||||
_setNativeTextInputFocused(false);
|
||||
final node = _installedFocusNode;
|
||||
if (node != null && identical(node.onKeyEvent, _keyHandler)) {
|
||||
node.onKeyEvent = _previousOnKeyEvent;
|
||||
if (node != null) {
|
||||
node.removeListener(_focusListener);
|
||||
if (identical(node.onKeyEvent, _keyHandler)) {
|
||||
node.onKeyEvent = _previousOnKeyEvent;
|
||||
}
|
||||
}
|
||||
_installedFocusNode = null;
|
||||
_previousOnKeyEvent = null;
|
||||
@@ -585,6 +641,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
Widget build(BuildContext context) {
|
||||
final focusNode = _effectiveFocusNode;
|
||||
_installKeyHandler(focusNode);
|
||||
_syncNativeTextInputFocus();
|
||||
return widget.builder(widget.input._hasTvKeyboard, focusNode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class FocusableWrapper extends StatefulWidget {
|
||||
/// Whether the wrapper can receive focus.
|
||||
final bool canRequestFocus;
|
||||
|
||||
/// Custom key event handler. Return KeyEventResult.handled to consume the event.
|
||||
/// Custom key event handler. Return any non-ignored result to stop default handling.
|
||||
/// This is called before the default key handling.
|
||||
final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent;
|
||||
|
||||
@@ -325,7 +325,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
// Call custom key handler first
|
||||
if (widget.onKeyEvent != null) {
|
||||
final result = widget.onKeyEvent!(node, event);
|
||||
if (result == KeyEventResult.handled) {
|
||||
if (result != KeyEventResult.ignored) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +451,8 @@ class GamepadService with WindowListener {
|
||||
FocusNode? node = FocusManager.instance.primaryFocus;
|
||||
while (node != null) {
|
||||
if (node.onKeyEvent != null) {
|
||||
if (node.onKeyEvent!(node, event) == KeyEventResult.handled) break;
|
||||
final result = node.onKeyEvent!(node, event);
|
||||
if (result != KeyEventResult.ignored) break;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
@@ -84,8 +84,9 @@ void simulateKeyUp(LogicalKeyboardKey logicalKey) {
|
||||
void _dispatchKeyEvent(FocusNode focusNode, KeyEvent event) {
|
||||
FocusNode? node = focusNode;
|
||||
while (node != null) {
|
||||
if (node.onKeyEvent != null && node.onKeyEvent!(node, event) == KeyEventResult.handled) {
|
||||
break;
|
||||
if (node.onKeyEvent != null) {
|
||||
final result = node.onKeyEvent!(node, event);
|
||||
if (result != KeyEventResult.ignored) break;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,45 @@ void main() {
|
||||
expect(events[1], isA<KeyUpEvent>());
|
||||
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
|
||||
});
|
||||
|
||||
testWidgets('simulateKeyPress stops at skipRemainingHandlers', (tester) async {
|
||||
final childEvents = <KeyEvent>[];
|
||||
final parentEvents = <KeyEvent>[];
|
||||
late BuildContext childContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Focus(
|
||||
onKeyEvent: (_, event) {
|
||||
parentEvents.add(event);
|
||||
return KeyEventResult.handled;
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (_, event) {
|
||||
childEvents.add(event);
|
||||
return KeyEventResult.skipRemainingHandlers;
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
childContext = context;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
Focus.of(childContext).requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
simulateKeyPress(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(childEvents, hasLength(2));
|
||||
expect(parentEvents, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<KeyEvent>> _pumpKeyEventRecorder(WidgetTester tester) async {
|
||||
|
||||
@@ -337,6 +337,55 @@ void main() {
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Android TV native text input focus is reported to platform', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
TvDetectionService.setForceTVSync(true);
|
||||
const channel = MethodChannel('com.plezy/text_input');
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
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),
|
||||
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(calls.last.method, 'setNativeTextInputFocused');
|
||||
expect(calls.last.arguments, isTrue);
|
||||
|
||||
otherFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(calls.last.method, 'setNativeTextInputFocused');
|
||||
expect(calls.last.arguments, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Android TV physical keyboard still uses field navigation', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
|
||||
Reference in New Issue
Block a user