From b2ec34fcb4459757d1ba9bf15736fbaf6e9631a6 Mon Sep 17 00:00:00 2001 From: Bruno Leroux Date: Thu, 26 Oct 2023 13:44:07 +0200 Subject: [PATCH] Add ProcessTextService (#137145) ## Description This PR adds `ProcessTextService` on the framework side to communicate with the engine to query and run text processing actions (on the engine side, only Android is supported currently, see https://github.com/flutter/engine/pull/44579). ## Related Issue Non-UI framework side for https://github.com/flutter/flutter/issues/107603 ## Tests Adds 3 tests. --- packages/flutter/lib/services.dart | 1 + .../lib/src/services/process_text.dart | 121 ++++++++++++++++++ .../lib/src/services/system_channels.dart | 8 ++ .../test/services/process_text_test.dart | 117 +++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 packages/flutter/lib/src/services/process_text.dart create mode 100644 packages/flutter/test/services/process_text_test.dart diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index cdd209fd1f..a5ed2f7ff4 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -33,6 +33,7 @@ export 'src/services/mouse_cursor.dart'; export 'src/services/mouse_tracking.dart'; export 'src/services/platform_channel.dart'; export 'src/services/platform_views.dart'; +export 'src/services/process_text.dart'; export 'src/services/raw_keyboard.dart'; export 'src/services/raw_keyboard_android.dart'; export 'src/services/raw_keyboard_fuchsia.dart'; diff --git a/packages/flutter/lib/src/services/process_text.dart b/packages/flutter/lib/src/services/process_text.dart new file mode 100644 index 0000000000..b823f3f9fb --- /dev/null +++ b/packages/flutter/lib/src/services/process_text.dart @@ -0,0 +1,121 @@ +// 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 'system_channels.dart'; + +/// A data structure describing text processing actions. +@immutable +class ProcessTextAction { + /// Creates text processing actions based on those returned by the engine. + const ProcessTextAction(this.id, this.label); + + /// The action unique id. + final String id; + + /// The action localized label. + final String label; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is ProcessTextAction && + other.id == id && + other.label == label; + } + + @override + int get hashCode => Object.hash(id, label); +} + +/// Determines how to interact with the text processing feature. +abstract class ProcessTextService { + /// Returns a [Future] that resolves to a [List] of [ProcessTextAction]s + /// containing all text processing actions available. + /// + /// If there are no actions available, an empty list will be returned. + Future> queryTextActions(); + + /// Returns a [Future] that resolves to a [String] when the text action + /// returns a transformed text or null when the text action did not return + /// a transformed text. + /// + /// The `id` parameter is the text action unique identifier returned by + /// [queryTextActions]. + /// + /// The `text` parameter is the text to be processed. + /// + /// The `readOnly` parameter indicates that the transformed text, if it exists, + /// will be used as read-only. + Future processTextAction(String id, String text, bool readOnly); +} + +/// The service used by default for the text processing feature. +/// +/// Any widget may use this service to get a list of text processing actions +/// and send requests to activate these text actions. +/// +/// This is currently only supported by Android. +/// +/// See also: +/// +/// * [ProcessTextService], the service that this implements. +class DefaultProcessTextService implements ProcessTextService { + /// Creates the default service to interact with the platform text processing + /// feature via communication over the text processing [MethodChannel]. + DefaultProcessTextService() { + _processTextChannel = SystemChannels.processText; + } + + /// The channel used to communicate with the engine side. + late MethodChannel _processTextChannel; + + /// Set the [MethodChannel] used to communicate with the engine text processing + /// feature. + /// + /// This is only meant for testing within the Flutter SDK. + @visibleForTesting + void setChannel(MethodChannel newChannel) { + assert(() { + _processTextChannel = newChannel; + return true; + }()); + } + + @override + Future> queryTextActions() async { + final List textActions = []; + final Map? rawResults; + + try { + rawResults = await _processTextChannel.invokeMethod( + 'ProcessText.queryTextActions', + ) as Map; + } catch (e) { + return textActions; + } + + for (final Object? id in rawResults.keys) { + textActions.add(ProcessTextAction(id! as String, rawResults[id]! as String)); + } + + return textActions; + } + + @override + /// On Android, the readOnly parameter might be used by the targeted activity, see: + /// https://developer.android.com/reference/android/content/Intent#EXTRA_PROCESS_TEXT_READONLY. + Future processTextAction(String id, String text, bool readOnly) async { + final String? processedText = await _processTextChannel.invokeMethod( + 'ProcessText.processTextAction', + [id, text, readOnly], + ) as String?; + + return processedText; + } +} diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index 5311d36c7c..24cf722442 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -144,6 +144,14 @@ abstract final class SystemChannels { JSONMethodCodec(), ); + /// A [MethodChannel] for handling text processing actions. + /// + /// This channel exposes the text processing feature for supported platforms. + /// Currently supported on Android only. + static const MethodChannel processText = OptionalMethodChannel( + 'flutter/processtext', + ); + /// A JSON [MethodChannel] for handling text input. /// /// This channel exposes a system text input control for interacting with IMEs diff --git a/packages/flutter/test/services/process_text_test.dart b/packages/flutter/test/services/process_text_test.dart new file mode 100644 index 0000000000..2ad9d642b6 --- /dev/null +++ b/packages/flutter/test/services/process_text_test.dart @@ -0,0 +1,117 @@ +// 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/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('ProcessTextService.queryTextActions emits correct method call', () async { + final List log = []; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + + final ProcessTextService processTextService = DefaultProcessTextService(); + await processTextService.queryTextActions(); + + expect(log, hasLength(1)); + expect(log.single, isMethodCall('ProcessText.queryTextActions', arguments: null)); + }); + + test('ProcessTextService.processTextAction emits correct method call', () async { + final List log = []; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.processText, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + + final ProcessTextService processTextService = DefaultProcessTextService(); + const String fakeActionId = 'fakeActivity.fakeAction'; + const String textToProcess = 'Flutter'; + await processTextService.processTextAction(fakeActionId, textToProcess, false); + + expect(log, hasLength(1)); + expect(log.single, isMethodCall('ProcessText.processTextAction', arguments: [fakeActionId, textToProcess, false])); + }); + + test('ProcessTextService handles engine answers over the channel', () async { + const String action1Id = 'fakeActivity.fakeAction1'; + const String action2Id = 'fakeActivity.fakeAction2'; + + // Fake channel that simulates responses returned from the engine. + final MethodChannel fakeChannel = FakeProcessTextChannel((MethodCall call) async { + if (call.method == 'ProcessText.queryTextActions') { + return { + action1Id: 'Action1', + action2Id: 'Action2', + }; + } + if (call.method == 'ProcessText.processTextAction') { + final List args = call.arguments as List; + final String actionId = args[0] as String; + final String testToProcess = args[1] as String; + if (actionId == action1Id) { + // Simulates an action that returns a transformed text. + return '$testToProcess!!!'; + } + // Simulates an action that failed or does not transform text. + return null; + } + }); + + final DefaultProcessTextService processTextService = DefaultProcessTextService(); + processTextService.setChannel(fakeChannel); + + final List actions = await processTextService.queryTextActions(); + expect(actions, hasLength(2)); + + const String textToProcess = 'Flutter'; + String? processedText; + + processedText = await processTextService.processTextAction(action1Id, textToProcess, false); + expect(processedText, 'Flutter!!!'); + + processedText = await processTextService.processTextAction(action2Id, textToProcess, false); + expect(processedText, null); + }); +} + +class FakeProcessTextChannel implements MethodChannel { + FakeProcessTextChannel(this.outgoing); + + Future Function(MethodCall) outgoing; + Future Function(MethodCall)? incoming; + + List outgoingCalls = []; + + @override + BinaryMessenger get binaryMessenger => throw UnimplementedError(); + + @override + MethodCodec get codec => const StandardMethodCodec(); + + @override + Future> invokeListMethod(String method, [dynamic arguments]) => throw UnimplementedError(); + + @override + Future> invokeMapMethod(String method, [dynamic arguments]) => throw UnimplementedError(); + + @override + Future invokeMethod(String method, [dynamic arguments]) async { + final MethodCall call = MethodCall(method, arguments); + outgoingCalls.add(call); + return await outgoing(call) as T; + } + + @override + String get name => 'flutter/processtext'; + + @override + void setMethodCallHandler(Future Function(MethodCall call)? handler) => incoming = handler; +}