Unverified Commit 89f0c69e authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

Add custom system-wide text selection toolbar buttons on Android (#139738)

## Description

This PR adds custom system-wide text selection toolbar buttons on Android.
~~This is a WIP until https://github.com/flutter/flutter/pull/139479 is merged (potential conflicts).~~

## Related Issue

Fixes https://github.com/flutter/flutter/issues/139361

## Tests

Adds 5 tests.
parent 6664dfec
......@@ -2199,6 +2199,12 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
bool get _spellCheckResultsReceived => spellCheckEnabled && spellCheckResults != null && spellCheckResults!.suggestionSpans.isNotEmpty;
/// The text processing service used to retrieve the native text processing actions.
final ProcessTextService _processTextService = DefaultProcessTextService();
/// The list of native text processing actions provided by the engine.
final List<ProcessTextAction> _processTextActions = <ProcessTextAction>[];
/// Whether to create an input connection with the platform for text editing
/// or not.
///
......@@ -2410,14 +2416,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
clipboardStatus.update();
}
bool get _allowPaste {
return !widget.readOnly && textEditingValue.selection.isValid;
}
/// Paste text from [Clipboard].
@override
Future<void> pasteText(SelectionChangedCause cause) async {
if (widget.readOnly) {
return;
}
final TextSelection selection = textEditingValue.selection;
if (!selection.isValid) {
if (!_allowPaste) {
return;
}
// Snapshot the input before using `await`.
......@@ -2426,16 +2432,24 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (data == null) {
return;
}
_pasteText(cause, data.text!);
}
void _pasteText(SelectionChangedCause cause, String text) {
if (!_allowPaste) {
return;
}
// After the paste, the cursor should be collapsed and located after the
// pasted content.
final TextSelection selection = textEditingValue.selection;
final int lastSelectionIndex = math.max(selection.baseOffset, selection.extentOffset);
final TextEditingValue collapsedTextEditingValue = textEditingValue.copyWith(
selection: TextSelection.collapsed(offset: lastSelectionIndex),
);
userUpdateTextEditingValue(
collapsedTextEditingValue.replaced(selection, data.text!),
collapsedTextEditingValue.replaced(selection, text),
cause,
);
if (cause == SelectionChangedCause.toolbar) {
......@@ -2789,7 +2803,35 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
onLiveTextInput: liveTextInputEnabled
? () => _startLiveTextInput(SelectionChangedCause.toolbar)
: null,
);
)..addAll(_textProcessingActionButtonItems);
}
List<ContextMenuButtonItem> get _textProcessingActionButtonItems {
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final TextSelection selection = textEditingValue.selection;
if (widget.obscureText || !selection.isValid || selection.isCollapsed) {
return buttonItems;
}
for (final ProcessTextAction action in _processTextActions) {
buttonItems.add(ContextMenuButtonItem(
label: action.label,
onPressed: () async {
final String selectedText = selection.textInside(textEditingValue.text);
if (selectedText.isNotEmpty) {
final String? processedText = await _processTextService.processTextAction(action.id, selectedText, widget.readOnly);
// If an activity does not return a modified version, just hide the toolbar.
// Otherwise use the result to replace the selected text.
if (processedText != null && _allowPaste) {
_pasteText(SelectionChangedCause.toolbar, processedText);
} else {
hideToolbar();
}
}
},
));
}
return buttonItems;
}
// State lifecycle:
......@@ -2804,6 +2846,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_scrollController.addListener(_onEditableScroll);
_cursorVisibilityNotifier.value = widget.showCursor;
_spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
_initProcessTextActions();
}
/// Query the engine to initialize the list of text processing actions to show
/// in the text selection toolbar.
Future<void> _initProcessTextActions() async {
_processTextActions.clear();
_processTextActions.addAll(await _processTextService.queryTextActions());
}
// Whether `TickerMode.of(context)` is true and animations (like blinking the
......
......@@ -27,6 +27,7 @@ import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart';
import '../widgets/live_text_utils.dart';
import '../widgets/process_text_utils.dart';
import '../widgets/semantics_tester.dart';
import '../widgets/text_selection_toolbar_utils.dart';
import 'feedback_tester.dart';
......@@ -17090,6 +17091,270 @@ void main() {
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));
// The toolbar is visible and the text processing actions are visible on Android.
final bool areTextActionsSupported = defaultTargetPlatform == TargetPlatform.android;
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text(fakeAction1Label), areTextActionsSupported ? findsOneWidget : findsNothing);
expect(find.text(fakeAction2Label), areTextActionsSupported ? findsOneWidget : findsNothing);
},
variant: TargetPlatformVariant.all(),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets(
'Text processing actions are not added to the toolbar for obscured text',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
obscureText: true,
controller: controller,
),
),
),
);
// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 14));
// The toolbar is visible but does not contain the text processing actions.
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text(fakeAction1Label), findsNothing);
expect(find.text(fakeAction2Label), findsNothing);
},
variant: TargetPlatformVariant.all(),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets(
'Text processing actions are not added to the toolbar if selection is collapsed (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
// Open the text selection toolbar.
await showSelectionMenuAt(tester, controller, initialText.indexOf('F'));
await skipPastScrollingAnimation(tester);
// The toolbar is visible but does not contain the text processing actions.
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(controller.selection.isCollapsed, true);
expect(find.text(fakeAction1Label), findsNothing);
expect(find.text(fakeAction2Label), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets(
'Invoke a text processing action that does not return a value (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));
// Run an action that does not return a processed text.
await tester.tap(find.text(fakeAction2Label));
await tester.pump(const Duration(milliseconds: 200));
// The action was correctly called.
expect(mockProcessTextHandler.lastCalledActionId, fakeAction2Id);
expect(mockProcessTextHandler.lastTextToProcess, 'Flutter');
// The text field was not updated.
expect(controller.text, initialText);
// The toolbar is no longer visible.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets(
'Invoking a text processing action that returns a value replaces the selection (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));
// Run an action that returns a processed text.
await tester.tap(find.text(fakeAction1Label));
await tester.pump(const Duration(milliseconds: 200));
// The action was correctly called.
expect(mockProcessTextHandler.lastCalledActionId, fakeAction1Id);
expect(mockProcessTextHandler.lastTextToProcess, 'Flutter');
// The text field was updated.
expect(controller.text, 'I love Flutter!!!');
// The toolbar is no longer visible.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets(
'Invoking a text processing action that returns a value does not replace the selection of a readOnly text field (Android only)',
(WidgetTester tester) async {
const String initialText = 'I love Flutter';
final TextEditingController controller = _textEditingController(text: initialText);
final MockProcessTextHandler mockProcessTextHandler = MockProcessTextHandler();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.processText, mockProcessTextHandler.handleMethodCall);
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, null));
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
readOnly: true,
controller: controller,
),
),
),
);
// Long press to put the cursor after the "F".
final int index = initialText.indexOf('F');
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 7, extentOffset: 14));
// Run an action that returns a processed text.
await tester.tap(find.text(fakeAction1Label));
await tester.pump(const Duration(milliseconds: 200));
// The Action was correctly called.
expect(mockProcessTextHandler.lastCalledActionId, fakeAction1Id);
expect(mockProcessTextHandler.lastTextToProcess, 'Flutter');
// The text field was not updated.
expect(controller.text, initialText);
// The toolbar is no longer visible.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
}
/// A Simple widget for testing the obscure text.
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
const String fakeAction1Id = 'fakeActivity.fakeAction1';
const String fakeAction2Id = 'fakeActivity.fakeAction2';
const String fakeAction1Label = 'Action1';
const String fakeAction2Label = 'Action2';
class MockProcessTextHandler {
String? lastCalledActionId;
String? lastTextToProcess;
Future<Object?> handleMethodCall(MethodCall call) async {
if (call.method == 'ProcessText.queryTextActions') {
// Simulate that only the Android engine will return a non-null result.
if (defaultTargetPlatform == TargetPlatform.android) {
return <String, String>{
fakeAction1Id: fakeAction1Label,
fakeAction2Id: fakeAction2Label,
};
}
}
if (call.method == 'ProcessText.processTextAction') {
final List<dynamic> args = call.arguments as List<dynamic>;
final String actionId = args[0] as String;
final String textToProcess = args[1] as String;
lastCalledActionId = actionId;
lastTextToProcess = textToProcess;
if (actionId == fakeAction1Id) {
// Simulates an action that returns a transformed text.
return '$textToProcess!!!';
}
// Simulates an action that failed or does not transform text.
return null;
}
return null;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment