diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 3a336880ef306..b8e32aa31564f 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -3091,6 +3091,7 @@ ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/syst ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/NavigationChannel.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformChannel.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/ProcessTextChannel.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/RestorationChannel.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/SpellCheckChannel.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/SystemChannel.java + ../../../flutter/LICENSE @@ -3133,6 +3134,7 @@ ORIGIN: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/Platf ORIGIN: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/SingleViewPresentation.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/VirtualDisplayController.java + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/android/io/flutter/plugin/text/ProcessTextPlugin.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/util/HandlerCompat.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/util/PathUtils.java + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/android/io/flutter/util/Preconditions.java + ../../../flutter/LICENSE @@ -5861,6 +5863,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/system FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/NavigationChannel.java FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformChannel.java FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java +FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/ProcessTextChannel.java FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/RestorationChannel.java FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/SettingsChannel.java FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/SpellCheckChannel.java @@ -5908,6 +5911,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/Platfor FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/SingleViewPresentation.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/SurfaceTexturePlatformViewRenderTarget.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/VirtualDisplayController.java +FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/text/ProcessTextPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/util/HandlerCompat.java FILE: ../../../flutter/shell/platform/android/io/flutter/util/PathUtils.java FILE: ../../../flutter/shell/platform/android/io/flutter/util/Preconditions.java diff --git a/shell/platform/android/AndroidManifest.xml b/shell/platform/android/AndroidManifest.xml index 5fbd0d8553c2b..97c77ee7ef57f 100644 --- a/shell/platform/android/AndroidManifest.xml +++ b/shell/platform/android/AndroidManifest.xml @@ -24,4 +24,12 @@ + + + + + + + + diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 06e4c9de02985..1f1068db7e703 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -265,6 +265,7 @@ android_java_sources = [ "io/flutter/embedding/engine/systemchannels/NavigationChannel.java", "io/flutter/embedding/engine/systemchannels/PlatformChannel.java", "io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java", + "io/flutter/embedding/engine/systemchannels/ProcessTextChannel.java", "io/flutter/embedding/engine/systemchannels/RestorationChannel.java", "io/flutter/embedding/engine/systemchannels/SettingsChannel.java", "io/flutter/embedding/engine/systemchannels/SpellCheckChannel.java", @@ -312,6 +313,7 @@ android_java_sources = [ "io/flutter/plugin/platform/SingleViewPresentation.java", "io/flutter/plugin/platform/SurfaceTexturePlatformViewRenderTarget.java", "io/flutter/plugin/platform/VirtualDisplayController.java", + "io/flutter/plugin/text/ProcessTextPlugin.java", "io/flutter/util/HandlerCompat.java", "io/flutter/util/PathUtils.java", "io/flutter/util/Preconditions.java", diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java b/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java index 2bfa379f39c1e..cb00ec42b363c 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java @@ -31,6 +31,7 @@ import io.flutter.embedding.engine.systemchannels.MouseCursorChannel; import io.flutter.embedding.engine.systemchannels.NavigationChannel; import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import io.flutter.embedding.engine.systemchannels.ProcessTextChannel; import io.flutter.embedding.engine.systemchannels.RestorationChannel; import io.flutter.embedding.engine.systemchannels.SettingsChannel; import io.flutter.embedding.engine.systemchannels.SpellCheckChannel; @@ -38,6 +39,7 @@ import io.flutter.embedding.engine.systemchannels.TextInputChannel; import io.flutter.plugin.localization.LocalizationPlugin; import io.flutter.plugin.platform.PlatformViewsController; +import io.flutter.plugin.text.ProcessTextPlugin; import io.flutter.util.ViewUtils; import java.util.HashSet; import java.util.List; @@ -95,6 +97,7 @@ public class FlutterEngine implements ViewUtils.DisplayUpdater { @NonNull private final NavigationChannel navigationChannel; @NonNull private final RestorationChannel restorationChannel; @NonNull private final PlatformChannel platformChannel; + @NonNull private final ProcessTextChannel processTextChannel; @NonNull private final SettingsChannel settingsChannel; @NonNull private final SpellCheckChannel spellCheckChannel; @NonNull private final SystemChannel systemChannel; @@ -329,6 +332,7 @@ public FlutterEngine( mouseCursorChannel = new MouseCursorChannel(dartExecutor); navigationChannel = new NavigationChannel(dartExecutor); platformChannel = new PlatformChannel(dartExecutor); + processTextChannel = new ProcessTextChannel(dartExecutor, context.getPackageManager()); restorationChannel = new RestorationChannel(dartExecutor, waitForRestorationData); settingsChannel = new SettingsChannel(dartExecutor); spellCheckChannel = new SpellCheckChannel(dartExecutor); @@ -384,6 +388,9 @@ public FlutterEngine( } ViewUtils.calculateMaximumDisplayMetrics(context, this); + + ProcessTextPlugin processTextPlugin = new ProcessTextPlugin(this.getProcessTextChannel()); + this.pluginRegistry.add(processTextPlugin); } private void attachToJni() { @@ -545,6 +552,12 @@ public PlatformChannel getPlatformChannel() { return platformChannel; } + /** System channel that sends text processing requests from Flutter to Android. */ + @NonNull + public ProcessTextChannel getProcessTextChannel() { + return processTextChannel; + } + /** * System channel to exchange restoration data between framework and engine. * diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/ProcessTextChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/ProcessTextChannel.java new file mode 100644 index 0000000000000..f1d12bc681111 --- /dev/null +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/ProcessTextChannel.java @@ -0,0 +1,122 @@ +// Copyright 2013 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. + +package io.flutter.embedding.engine.systemchannels; + +import android.content.pm.PackageManager; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.embedding.engine.dart.DartExecutor; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.StandardMethodCodec; +import java.util.ArrayList; +import java.util.Map; + +/** + * {@link ProcessTextChannel} is a platform channel that is used by the framework to initiate text + * processing feature in the embedding and for the embedding to send back the results. + * + *

When the framework needs to query the list of text processing actions (for instance to expose + * them in the selected text context menu), it will send to the embedding the message {@code + * ProcessText.queryTextActions}. In response, the {@link io.flutter.plugin.text.ProcessTextPlugin} + * will return a map of all activities that can process text. The map keys are generated IDs and the + * values are the activities labels. On the first request, the {@link + * io.flutter.plugin.text.ProcessTextPlugin} will make a call to Android's package manager to query + * all activities that can be performed for the {@code Intent.ACTION_PROCESS_TEXT} intent. + * + *

When a text processing action has to be executed, the framework will send to the embedding the + * message {@code ProcessText.processTextAction} with the {@code int id} of the choosen text action + * and the {@code String} of text to process as arguments. In response, the {@link + * io.flutter.plugin.text.ProcessTextPlugin} will make a call to the Android application activity to + * start the activity exposing the text action. The {@link io.flutter.plugin.text.ProcessTextPlugin} + * will return the processed text if there is one, or null if the activity did not return a + * transformed text. + * + *

{@link io.flutter.plugin.text.ProcessTextPlugin} implements {@link ProcessTextMethodHandler} + * that parses incoming messages from Flutter. + */ +public class ProcessTextChannel { + private static final String TAG = "ProcessTextChannel"; + private static final String CHANNEL_NAME = "flutter/processtext"; + private static final String METHOD_QUERY_TEXT_ACTIONS = "ProcessText.queryTextActions"; + private static final String METHOD_PROCESS_TEXT_ACTION = "ProcessText.processTextAction"; + + public final MethodChannel channel; + public final PackageManager packageManager; + private ProcessTextMethodHandler processTextMethodHandler; + + @NonNull + public final MethodChannel.MethodCallHandler parsingMethodHandler = + new MethodChannel.MethodCallHandler() { + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + if (processTextMethodHandler == null) { + return; + } + String method = call.method; + Object args = call.arguments; + switch (method) { + case METHOD_QUERY_TEXT_ACTIONS: + try { + Map actions = processTextMethodHandler.queryTextActions(); + result.success(actions); + } catch (IllegalStateException exception) { + result.error("error", exception.getMessage(), null); + } + break; + case METHOD_PROCESS_TEXT_ACTION: + try { + final ArrayList argumentList = (ArrayList) args; + String id = (String) (argumentList.get(0)); + String text = (String) (argumentList.get(1)); + boolean readOnly = (boolean) (argumentList.get(2)); + processTextMethodHandler.processTextAction(id, text, readOnly, result); + } catch (IllegalStateException exception) { + result.error("error", exception.getMessage(), null); + } + break; + default: + result.notImplemented(); + break; + } + } + }; + + public ProcessTextChannel( + @NonNull DartExecutor dartExecutor, @NonNull PackageManager packageManager) { + this.packageManager = packageManager; + channel = new MethodChannel(dartExecutor, CHANNEL_NAME, StandardMethodCodec.INSTANCE); + channel.setMethodCallHandler(parsingMethodHandler); + } + + /** + * Sets the {@link ProcessTextMethodHandler} which receives all requests to the text processing + * feature sent through this channel. + */ + public void setMethodHandler(@Nullable ProcessTextMethodHandler processTextMethodHandler) { + this.processTextMethodHandler = processTextMethodHandler; + } + + public interface ProcessTextMethodHandler { + /** Requests the map of text actions. Each text action has a unique id and a localized label. */ + Map queryTextActions(); + + /** + * Requests to run a text action on a given input text. + * + * @param id The ID of the text action returned by {@code ProcessText.queryTextActions}. + * @param input The text to be processed. + * @param readOnly Indicates to the activity if the processed text will be used as read-only. + * see + * https://developer.android.com/reference/android/content/Intent#EXTRA_PROCESS_TEXT_READONLY + * @param result The method channel result instance used to reply. + */ + void processTextAction( + @NonNull String id, + @NonNull String input, + @NonNull boolean readOnly, + @NonNull MethodChannel.Result result); + } +} diff --git a/shell/platform/android/io/flutter/plugin/text/ProcessTextPlugin.java b/shell/platform/android/io/flutter/plugin/text/ProcessTextPlugin.java new file mode 100644 index 0000000000000..d04716e4cb410 --- /dev/null +++ b/shell/platform/android/io/flutter/plugin/text/ProcessTextPlugin.java @@ -0,0 +1,192 @@ +// Copyright 2013 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. + +package io.flutter.plugin.text; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.systemchannels.ProcessTextChannel; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry.ActivityResultListener; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ProcessTextPlugin + implements FlutterPlugin, + ActivityAware, + ActivityResultListener, + ProcessTextChannel.ProcessTextMethodHandler { + private static final String TAG = "ProcessTextPlugin"; + + @NonNull private final ProcessTextChannel processTextChannel; + @NonNull private final PackageManager packageManager; + @Nullable private ActivityPluginBinding activityBinding; + private Map resolveInfosById; + + @NonNull + private Map requestsByCode = + new HashMap(); + + public ProcessTextPlugin(@NonNull ProcessTextChannel processTextChannel) { + this.processTextChannel = processTextChannel; + this.packageManager = processTextChannel.packageManager; + + processTextChannel.setMethodHandler(this); + } + + @Override + public Map queryTextActions() { + if (resolveInfosById == null) { + cacheResolveInfos(); + } + Map result = new HashMap(); + for (String id : resolveInfosById.keySet()) { + final ResolveInfo info = resolveInfosById.get(id); + result.put(id, info.loadLabel(packageManager).toString()); + } + return result; + } + + @Override + public void processTextAction( + @NonNull String id, + @NonNull String text, + @NonNull boolean readOnly, + @NonNull MethodChannel.Result result) { + if (activityBinding == null) { + result.error("error", "Plugin not bound to an Activity", null); + return; + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + result.error("error", "Android version not supported", null); + return; + } + + if (resolveInfosById == null) { + result.error("error", "Can not process text actions before calling queryTextActions", null); + return; + } + + final ResolveInfo info = resolveInfosById.get(id); + if (info == null) { + result.error("error", "Text processing activity not found", null); + return; + } + + Integer requestCode = result.hashCode(); + requestsByCode.put(requestCode, result); + + Intent intent = new Intent(); + intent.setClassName(info.activityInfo.packageName, info.activityInfo.name); + intent.setAction(Intent.ACTION_PROCESS_TEXT); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_PROCESS_TEXT, text); + intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, readOnly); + + // Start the text processing activity. When the activity completes, the onActivityResult + // callback + // is called. + activityBinding.getActivity().startActivityForResult(intent, requestCode); + } + + private void cacheResolveInfos() { + resolveInfosById = new HashMap(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return; + } + + Intent intent = new Intent().setAction(Intent.ACTION_PROCESS_TEXT).setType("text/plain"); + + List infos; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + infos = packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(0)); + } else { + infos = packageManager.queryIntentActivities(intent, 0); + } + + for (ResolveInfo info : infos) { + final String id = info.activityInfo.name; + final String label = info.loadLabel(packageManager).toString(); + resolveInfosById.put(id, info); + } + } + + /** + * Executed when a text processing activity terminates. + * + *

When an activity returns a value, the request is completed successfully and returns the + * processed text. + * + *

When an activity does not return a value. the request is completed successfully and returns + * null. + */ + @TargetApi(Build.VERSION_CODES.M) + @RequiresApi(Build.VERSION_CODES.M) + public boolean onActivityResult(int requestCode, int resultCode, @Nullable Intent intent) { + String result = null; + if (resultCode == Activity.RESULT_OK) { + result = intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT); + } + requestsByCode.remove(requestCode).success(result); + return true; + } + + /** + * Unregisters this {@code ProcessTextPlugin} as the {@code + * ProcessTextChannel.ProcessTextMethodHandler}, for the {@link + * io.flutter.embedding.engine.systemchannels.ProcessTextChannel}. + * + *

Do not invoke any methods on a {@code ProcessTextPlugin} after invoking this method. + */ + public void destroy() { + processTextChannel.setMethodHandler(null); + } + + // FlutterPlugin interface implementation. + + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + // Nothing to do because this plugin is instantiated by the engine. + } + + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + // Nothing to do because this plugin is instantiated by the engine. + } + + // ActivityAware interface implementation. + // + // Store the binding and manage the activity result listener. + + public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { + this.activityBinding = binding; + this.activityBinding.addActivityResultListener(this); + }; + + public void onDetachedFromActivityForConfigChanges() { + this.activityBinding.removeActivityResultListener(this); + this.activityBinding = null; + } + + public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { + this.activityBinding = binding; + this.activityBinding.addActivityResultListener(this); + } + + public void onDetachedFromActivity() { + this.activityBinding.removeActivityResultListener(this); + this.activityBinding = null; + } +} diff --git a/shell/platform/android/test/io/flutter/plugin/text/ProcessTextPluginTest.java b/shell/platform/android/test/io/flutter/plugin/text/ProcessTextPluginTest.java new file mode 100644 index 0000000000000..47448fd9f9f65 --- /dev/null +++ b/shell/platform/android/test/io/flutter/plugin/text/ProcessTextPluginTest.java @@ -0,0 +1,225 @@ +package io.flutter.plugin.text; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageItemInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Build; +import androidx.annotation.RequiresApi; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import io.flutter.embedding.engine.dart.DartExecutor; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.systemchannels.ProcessTextChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.StandardMethodCodec; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +@RunWith(AndroidJUnit4.class) +@TargetApi(Build.VERSION_CODES.N) +@RequiresApi(Build.VERSION_CODES.N) +public class ProcessTextPluginTest { + + private static void sendToBinaryMessageHandler( + BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) { + MethodCall methodCall = new MethodCall(method, args); + ByteBuffer encodedMethodCall = StandardMethodCodec.INSTANCE.encodeMethodCall(methodCall); + binaryMessageHandler.onMessage( + (ByteBuffer) encodedMethodCall.flip(), mock(BinaryMessenger.BinaryReply.class)); + } + + @SuppressWarnings("deprecation") + // setMessageHandler is deprecated. + @Test + public void respondsToProcessTextChannelMessage() { + ArgumentCaptor binaryMessageHandlerCaptor = + ArgumentCaptor.forClass(BinaryMessenger.BinaryMessageHandler.class); + DartExecutor mockBinaryMessenger = mock(DartExecutor.class); + ProcessTextChannel.ProcessTextMethodHandler mockHandler = + mock(ProcessTextChannel.ProcessTextMethodHandler.class); + PackageManager mockPackageManager = mock(PackageManager.class); + ProcessTextChannel processTextChannel = + new ProcessTextChannel(mockBinaryMessenger, mockPackageManager); + + processTextChannel.setMethodHandler(mockHandler); + + verify(mockBinaryMessenger, times(1)) + .setMessageHandler(any(String.class), binaryMessageHandlerCaptor.capture()); + + BinaryMessenger.BinaryMessageHandler binaryMessageHandler = + binaryMessageHandlerCaptor.getValue(); + + sendToBinaryMessageHandler(binaryMessageHandler, "ProcessText.queryTextActions", null); + + verify(mockHandler).queryTextActions(); + } + + @SuppressWarnings("deprecation") + // setMessageHandler is deprecated. + @Test + public void performQueryTextActions() { + DartExecutor mockBinaryMessenger = mock(DartExecutor.class); + PackageManager mockPackageManager = mock(PackageManager.class); + ProcessTextChannel processTextChannel = + new ProcessTextChannel(mockBinaryMessenger, mockPackageManager); + + // Set up mocked result for PackageManager.queryIntentActivities. + ResolveInfo action1 = createFakeResolveInfo("Action1", mockPackageManager); + ResolveInfo action2 = createFakeResolveInfo("Action2", mockPackageManager); + List infos = new ArrayList(Arrays.asList(action1, action2)); + Intent intent = new Intent().setAction(Intent.ACTION_PROCESS_TEXT).setType("text/plain"); + when(mockPackageManager.queryIntentActivities( + any(Intent.class), any(PackageManager.ResolveInfoFlags.class))) + .thenReturn(infos); + + // ProcessTextPlugin should retrieve the mocked text actions. + ProcessTextPlugin processTextPlugin = new ProcessTextPlugin(processTextChannel); + Map textActions = processTextPlugin.queryTextActions(); + final String action1Id = "mockActivityName.Action1"; + final String action2Id = "mockActivityName.Action2"; + assertEquals(textActions, Map.of(action1Id, "Action1", action2Id, "Action2")); + } + + @SuppressWarnings("deprecation") + // setMessageHandler is deprecated. + @Test + public void performProcessTextActionWithNoReturnedValue() { + DartExecutor mockBinaryMessenger = mock(DartExecutor.class); + PackageManager mockPackageManager = mock(PackageManager.class); + ProcessTextChannel processTextChannel = + new ProcessTextChannel(mockBinaryMessenger, mockPackageManager); + + // Set up mocked result for PackageManager.queryIntentActivities. + ResolveInfo action1 = createFakeResolveInfo("Action1", mockPackageManager); + ResolveInfo action2 = createFakeResolveInfo("Action2", mockPackageManager); + List infos = new ArrayList(Arrays.asList(action1, action2)); + when(mockPackageManager.queryIntentActivities( + any(Intent.class), any(PackageManager.ResolveInfoFlags.class))) + .thenReturn(infos); + + // ProcessTextPlugin should retrieve the mocked text actions. + ProcessTextPlugin processTextPlugin = new ProcessTextPlugin(processTextChannel); + Map textActions = processTextPlugin.queryTextActions(); + final String action1Id = "mockActivityName.Action1"; + final String action2Id = "mockActivityName.Action2"; + assertEquals(textActions, Map.of(action1Id, "Action1", action2Id, "Action2")); + + // Set up the activity binding. + ActivityPluginBinding mockActivityPluginBinding = mock(ActivityPluginBinding.class); + Activity mockActivity = mock(Activity.class); + when(mockActivityPluginBinding.getActivity()).thenReturn(mockActivity); + processTextPlugin.onAttachedToActivity(mockActivityPluginBinding); + + // Execute th first action. + String textToBeProcessed = "Flutter!"; + MethodChannel.Result result = mock(MethodChannel.Result.class); + processTextPlugin.processTextAction(action1Id, textToBeProcessed, false, result); + + // Activity.startActivityForResult should have been called. + ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(mockActivity, times(1)).startActivityForResult(intentCaptor.capture(), anyInt()); + Intent intent = intentCaptor.getValue(); + assertEquals(intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT), textToBeProcessed); + + // Simulate an Android activity answer which does not return a value. + Intent resultIntent = new Intent(); + processTextPlugin.onActivityResult(result.hashCode(), Activity.RESULT_OK, resultIntent); + + // Success with no returned value is expected. + verify(result).success(null); + } + + @SuppressWarnings("deprecation") + // setMessageHandler is deprecated. + @Test + public void performProcessTextActionWithReturnedValue() { + DartExecutor mockBinaryMessenger = mock(DartExecutor.class); + PackageManager mockPackageManager = mock(PackageManager.class); + ProcessTextChannel processTextChannel = + new ProcessTextChannel(mockBinaryMessenger, mockPackageManager); + + // Set up mocked result for PackageManager.queryIntentActivities. + ResolveInfo action1 = createFakeResolveInfo("Action1", mockPackageManager); + ResolveInfo action2 = createFakeResolveInfo("Action2", mockPackageManager); + List infos = new ArrayList(Arrays.asList(action1, action2)); + when(mockPackageManager.queryIntentActivities( + any(Intent.class), any(PackageManager.ResolveInfoFlags.class))) + .thenReturn(infos); + + // ProcessTextPlugin should retrieve the mocked text actions. + ProcessTextPlugin processTextPlugin = new ProcessTextPlugin(processTextChannel); + Map textActions = processTextPlugin.queryTextActions(); + final String action1Id = "mockActivityName.Action1"; + final String action2Id = "mockActivityName.Action2"; + assertEquals(textActions, Map.of(action1Id, "Action1", action2Id, "Action2")); + + // Set up the activity binding. + ActivityPluginBinding mockActivityPluginBinding = mock(ActivityPluginBinding.class); + Activity mockActivity = mock(Activity.class); + when(mockActivityPluginBinding.getActivity()).thenReturn(mockActivity); + processTextPlugin.onAttachedToActivity(mockActivityPluginBinding); + + // Execute the first action. + String textToBeProcessed = "Flutter!"; + MethodChannel.Result result = mock(MethodChannel.Result.class); + processTextPlugin.processTextAction(action1Id, textToBeProcessed, false, result); + + // Activity.startActivityForResult should have been called. + ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(mockActivity, times(1)).startActivityForResult(intentCaptor.capture(), anyInt()); + Intent intent = intentCaptor.getValue(); + assertEquals(intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT), textToBeProcessed); + + // Simulate an Android activity answer which returns a transformed text. + String processedText = "Flutter!!!"; + Intent resultIntent = new Intent(); + resultIntent.putExtra(Intent.EXTRA_PROCESS_TEXT, processedText); + processTextPlugin.onActivityResult(result.hashCode(), Activity.RESULT_OK, resultIntent); + + // Success with the transformed text is expected. + verify(result).success(processedText); + } + + private ResolveInfo createFakeResolveInfo(String label, PackageManager mockPackageManager) { + ResolveInfo resolveInfo = mock(ResolveInfo.class); + ActivityInfo activityInfo = new ActivityInfo(); + when(resolveInfo.loadLabel(mockPackageManager)).thenReturn(label); + + // Use Java reflection to set required member variables. + try { + Field activityField = ResolveInfo.class.getDeclaredField("activityInfo"); + activityField.setAccessible(true); + activityField.set(resolveInfo, activityInfo); + Field packageNameField = PackageItemInfo.class.getDeclaredField("packageName"); + packageNameField.setAccessible(true); + packageNameField.set(activityInfo, "mockActivityPackageName"); + Field nameField = PackageItemInfo.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(activityInfo, "mockActivityName." + label); + } catch (Exception ex) { + // Test will failed if reflection APIs throw. + } + + return resolveInfo; + } +}