fix(tv): preserve native text input routing

This commit is contained in:
edde746
2026-05-19 11:17:44 +02:00
parent 48cf8accfa
commit d2650667b8
7 changed files with 178 additions and 9 deletions
@@ -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" -> {
+59 -2
View File
@@ -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);
}
}
+2 -2
View File
@@ -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;
}
}
+2 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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;
}
+39
View File
@@ -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);