diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index e405341d083c1..acf7f8b0f624b 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -1399,6 +1399,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/system FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.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 FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/SystemChannel.java FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/common/ActivityLifecycleListener.java @@ -1423,6 +1424,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/FlutterT FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/SpellCheckPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/TextEditingDelta.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/localization/LocalizationPlugin.java diff --git a/lib/ui/platform_dispatcher.dart b/lib/ui/platform_dispatcher.dart index ef22df4bae286..8a1968c81ab25 100644 --- a/lib/ui/platform_dispatcher.dart +++ b/lib/ui/platform_dispatcher.dart @@ -842,6 +842,14 @@ class PlatformDispatcher { _onTextScaleFactorChangedZone = Zone.current; } + /// Whether the spell check service is supported on the current platform. + /// + /// This option is used by [EditableTextState] to define its + /// [SpellCheckConfiguration] when a default spell check service + /// is requested. + bool get nativeSpellCheckServiceDefined => _nativeSpellCheckServiceDefined; + bool _nativeSpellCheckServiceDefined = false; + /// Whether briefly displaying the characters as you type in obscured text /// fields is enabled in system settings. /// @@ -903,6 +911,12 @@ class PlatformDispatcher { final double textScaleFactor = (data['textScaleFactor']! as num).toDouble(); final bool alwaysUse24HourFormat = data['alwaysUse24HourFormat']! as bool; + final bool? nativeSpellCheckServiceDefined = data['nativeSpellCheckServiceDefined'] as bool?; + if (nativeSpellCheckServiceDefined != null) { + _nativeSpellCheckServiceDefined = nativeSpellCheckServiceDefined; + } else { + _nativeSpellCheckServiceDefined = false; + } // This field is optional. final bool? brieflyShowPassword = data['brieflyShowPassword'] as bool?; if (brieflyShowPassword != null) { diff --git a/lib/ui/window.dart b/lib/ui/window.dart index 0d9249144e092..48716f55c0292 100644 --- a/lib/ui/window.dart +++ b/lib/ui/window.dart @@ -425,6 +425,15 @@ class SingletonFlutterWindow extends FlutterWindow { /// observe when this value changes. double get textScaleFactor => platformDispatcher.textScaleFactor; + /// Whether the spell check service is supported on the current platform. + /// + /// {@macro dart.ui.window.accessorForwardWarning} + /// + /// This option is used by [EditableTextState] to define its + /// [SpellCheckConfiguration] when spell check is enabled, but no spell check + /// service is specified. + bool get nativeSpellCheckServiceDefined => platformDispatcher.nativeSpellCheckServiceDefined; + /// Whether briefly displaying the characters as you type in obscured text /// fields is enabled in system settings. /// diff --git a/lib/web_ui/lib/platform_dispatcher.dart b/lib/web_ui/lib/platform_dispatcher.dart index 7e72bee0c440f..5cc6175738e3e 100644 --- a/lib/web_ui/lib/platform_dispatcher.dart +++ b/lib/web_ui/lib/platform_dispatcher.dart @@ -82,6 +82,8 @@ abstract class PlatformDispatcher { double get textScaleFactor => configuration.textScaleFactor; + bool get nativeSpellCheckServiceDefined => false; + bool get brieflyShowPassword => true; VoidCallback? get onTextScaleFactorChanged; diff --git a/lib/web_ui/lib/window.dart b/lib/web_ui/lib/window.dart index fa2206778b870..43711551cd9a6 100644 --- a/lib/web_ui/lib/window.dart +++ b/lib/web_ui/lib/window.dart @@ -48,6 +48,8 @@ abstract class SingletonFlutterWindow extends FlutterWindow { double get textScaleFactor => platformDispatcher.textScaleFactor; + bool get nativeSpellCheckServiceDefined => platformDispatcher.nativeSpellCheckServiceDefined; + bool get brieflyShowPassword => platformDispatcher.brieflyShowPassword; bool get alwaysUse24HourFormat => platformDispatcher.alwaysUse24HourFormat; diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 204a5b3355e09..a12c8dfee3896 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -236,6 +236,7 @@ android_java_sources = [ "io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java", "io/flutter/embedding/engine/systemchannels/RestorationChannel.java", "io/flutter/embedding/engine/systemchannels/SettingsChannel.java", + "io/flutter/embedding/engine/systemchannels/SpellCheckChannel.java", "io/flutter/embedding/engine/systemchannels/SystemChannel.java", "io/flutter/embedding/engine/systemchannels/TextInputChannel.java", "io/flutter/plugin/common/ActivityLifecycleListener.java", @@ -260,6 +261,7 @@ android_java_sources = [ "io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java", "io/flutter/plugin/editing/InputConnectionAdaptor.java", "io/flutter/plugin/editing/ListenableEditingState.java", + "io/flutter/plugin/editing/SpellCheckPlugin.java", "io/flutter/plugin/editing/TextEditingDelta.java", "io/flutter/plugin/editing/TextInputPlugin.java", "io/flutter/plugin/localization/LocalizationPlugin.java", diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index d58cdba70a7f2..538249703216b 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -35,6 +35,8 @@ import android.view.autofill.AutofillValue; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; +import android.view.textservice.SpellCheckerInfo; +import android.view.textservice.TextServicesManager; import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -58,6 +60,7 @@ import io.flutter.embedding.engine.renderer.RenderSurface; import io.flutter.embedding.engine.systemchannels.SettingsChannel; import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.editing.SpellCheckPlugin; import io.flutter.plugin.editing.TextInputPlugin; import io.flutter.plugin.localization.LocalizationPlugin; import io.flutter.plugin.mouse.MouseCursorPlugin; @@ -126,10 +129,12 @@ public class FlutterView extends FrameLayout // existing, stateless system channels, e.g., MouseCursorChannel, TextInputChannel, etc. @Nullable private MouseCursorPlugin mouseCursorPlugin; @Nullable private TextInputPlugin textInputPlugin; + @Nullable private SpellCheckPlugin spellCheckPlugin; @Nullable private LocalizationPlugin localizationPlugin; @Nullable private KeyboardManager keyboardManager; @Nullable private AndroidTouchProcessor androidTouchProcessor; @Nullable private AccessibilityBridge accessibilityBridge; + @Nullable private TextServicesManager textServicesManager; // Provides access to foldable/hinge information @Nullable private WindowInfoRepositoryCallbackAdapterWrapper windowInfoRepo; @@ -1141,6 +1146,17 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) { this, this.flutterEngine.getTextInputChannel(), this.flutterEngine.getPlatformViewsController()); + + try { + textServicesManager = + (TextServicesManager) + getContext().getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE); + spellCheckPlugin = + new SpellCheckPlugin(textServicesManager, this.flutterEngine.getSpellCheckChannel()); + } catch (Exception e) { + Log.e(TAG, "TextServicesManager not supported by device, spell check disabled."); + } + localizationPlugin = this.flutterEngine.getLocalizationPlugin(); keyboardManager = new KeyboardManager(this); @@ -1238,6 +1254,9 @@ public void detachFromFlutterEngine() { textInputPlugin.getInputMethodManager().restartInput(this); textInputPlugin.destroy(); keyboardManager.destroy(); + if (spellCheckPlugin != null) { + spellCheckPlugin.destroy(); + } if (mouseCursorPlugin != null) { mouseCursorPlugin.destroy(); @@ -1422,10 +1441,34 @@ public void removeFlutterEngineAttachmentListener( ? SettingsChannel.PlatformBrightness.dark : SettingsChannel.PlatformBrightness.light; + boolean isNativeSpellCheckServiceDefined = false; + + if (textServicesManager != null) { + if (Build.VERSION.SDK_INT >= 31) { + List enabledSpellCheckerInfos = + textServicesManager.getEnabledSpellCheckerInfos(); + boolean gboardSpellCheckerEnabled = + enabledSpellCheckerInfos.stream() + .anyMatch( + spellCheckerInfo -> + spellCheckerInfo + .getPackageName() + .equals("com.google.android.inputmethod.latin")); + + // Checks if enabled spell checker is the one that is suppported by Gboard, which is + // the one Flutter supports by default. + isNativeSpellCheckServiceDefined = + textServicesManager.isSpellCheckerEnabled() && gboardSpellCheckerEnabled; + } else { + isNativeSpellCheckServiceDefined = true; + } + } + flutterEngine .getSettingsChannel() .startMessage() .setTextScaleFactor(getResources().getConfiguration().fontScale) + .setNativeSpellCheckServiceDefined(isNativeSpellCheckServiceDefined) .setBrieflyShowPassword( Settings.System.getInt( getContext().getContentResolver(), Settings.System.TEXT_SHOW_PASSWORD, 1) diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java b/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java index e172a87e55448..518e18c733d26 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterEngine.java @@ -32,6 +32,7 @@ import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.embedding.engine.systemchannels.RestorationChannel; import io.flutter.embedding.engine.systemchannels.SettingsChannel; +import io.flutter.embedding.engine.systemchannels.SpellCheckChannel; import io.flutter.embedding.engine.systemchannels.SystemChannel; import io.flutter.embedding.engine.systemchannels.TextInputChannel; import io.flutter.plugin.localization.LocalizationPlugin; @@ -93,6 +94,7 @@ public class FlutterEngine { @NonNull private final RestorationChannel restorationChannel; @NonNull private final PlatformChannel platformChannel; @NonNull private final SettingsChannel settingsChannel; + @NonNull private final SpellCheckChannel spellCheckChannel; @NonNull private final SystemChannel systemChannel; @NonNull private final TextInputChannel textInputChannel; @@ -306,6 +308,7 @@ public FlutterEngine( platformChannel = new PlatformChannel(dartExecutor); restorationChannel = new RestorationChannel(dartExecutor, waitForRestorationData); settingsChannel = new SettingsChannel(dartExecutor); + spellCheckChannel = new SpellCheckChannel(dartExecutor); systemChannel = new SystemChannel(dartExecutor); textInputChannel = new TextInputChannel(dartExecutor); @@ -550,6 +553,12 @@ public TextInputChannel getTextInputChannel() { return textInputChannel; } + /** System channel that sends and receives spell check requests and results. */ + @NonNull + public SpellCheckChannel getSpellCheckChannel() { + return spellCheckChannel; + } + /** * Plugin registry, which registers plugins that want to be applied to this {@code FlutterEngine}. */ diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/SettingsChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/SettingsChannel.java index 1be798c384cc6..46bf51190c147 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/SettingsChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/SettingsChannel.java @@ -13,6 +13,7 @@ public class SettingsChannel { public static final String CHANNEL_NAME = "flutter/settings"; private static final String TEXT_SCALE_FACTOR = "textScaleFactor"; + private static final String NATIVE_SPELL_CHECK_SERVICE_DEFINED = "nativeSpellCheckServiceDefined"; private static final String BRIEFLY_SHOW_PASSWORD = "brieflyShowPassword"; private static final String ALWAYS_USE_24_HOUR_FORMAT = "alwaysUse24HourFormat"; private static final String PLATFORM_BRIGHTNESS = "platformBrightness"; @@ -42,6 +43,13 @@ public MessageBuilder setTextScaleFactor(float textScaleFactor) { return this; } + @NonNull + public MessageBuilder setNativeSpellCheckServiceDefined( + boolean nativeSpellCheckServiceDefined) { + message.put(NATIVE_SPELL_CHECK_SERVICE_DEFINED, nativeSpellCheckServiceDefined); + return this; + } + @NonNull public MessageBuilder setBrieflyShowPassword(@NonNull boolean brieflyShowPassword) { message.put(BRIEFLY_SHOW_PASSWORD, brieflyShowPassword); diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/SpellCheckChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/SpellCheckChannel.java new file mode 100644 index 0000000000000..4c8fbec04b475 --- /dev/null +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/SpellCheckChannel.java @@ -0,0 +1,101 @@ +// 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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.Log; +import io.flutter.embedding.engine.dart.DartExecutor; +import io.flutter.plugin.common.JSONMethodCodec; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import org.json.JSONArray; +import org.json.JSONException; + +/** + * {@link SpellCheckChannel} is a platform channel that is used by the framework to initiate spell + * check in the embedding and for the embedding to send back the results. + * + *

When there is new text to be spell checked, the framework will send to the embedding the + * message {@code SpellCheck.initiateSpellCheck} with the {@code String} locale to spell check with + * and the {@code String} of text to spell check as arguments. In response, the {@link + * io.flutter.plugin.editing.SpellCheckPlugin} will make a call to Android's spell check service to + * fetch spell check results for the specified text. + * + *

Once the spell check results are received by the {@link + * io.flutter.plugin.editing.SpellCheckPlugin}, it will send to the framework the message {@code + * SpellCheck.updateSpellCheckResults} with the {@code ArrayList} of encoded spell check + * results (see {@link + * io.flutter.plugin.editing.SpellCheckPlugin#onGetSentenceSuggestions(SentenceSuggestionsInfo[])} + * for details) with the text that these results correspond to appeneded to the front as an + * argument. For example, the argument may look like: {@code {"Hello, wrold!", + * "7.11.world\nword\nold"}}. + * + *

{@link io.flutter.plugin.editing.SpellCheckPlugin} implements {@link SpellCheckMethodHandler} + * to initiate spell check. Implement {@link SpellCheckMethodHandler} to respond to spell check + * requests. + */ +public class SpellCheckChannel { + private static final String TAG = "SpellCheckChannel"; + + public final MethodChannel channel; + private SpellCheckMethodHandler spellCheckMethodHandler; + + @NonNull + public final MethodChannel.MethodCallHandler parsingMethodHandler = + new MethodChannel.MethodCallHandler() { + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + if (spellCheckMethodHandler == null) { + Log.v( + TAG, + "No SpellCheckeMethodHandler registered, call not forwarded to spell check API."); + return; + } + String method = call.method; + Object args = call.arguments; + Log.v(TAG, "Received '" + method + "' message."); + switch (method) { + case "SpellCheck.initiateSpellCheck": + try { + final JSONArray argumentList = (JSONArray) args; + String locale = argumentList.getString(0); + String text = argumentList.getString(1); + spellCheckMethodHandler.initiateSpellCheck(locale, text, result); + } catch (JSONException exception) { + result.error("error", exception.getMessage(), null); + } + break; + default: + result.notImplemented(); + break; + } + } + }; + + public SpellCheckChannel(@NonNull DartExecutor dartExecutor) { + channel = new MethodChannel(dartExecutor, "flutter/spellcheck", JSONMethodCodec.INSTANCE); + channel.setMethodCallHandler(parsingMethodHandler); + } + + /** + * Sets the {@link SpellCheckMethodHandler} which receives all requests to spell check the + * specified text sent through this channel. + */ + public void setSpellCheckMethodHandler( + @Nullable SpellCheckMethodHandler spellCheckMethodHandler) { + this.spellCheckMethodHandler = spellCheckMethodHandler; + } + + public interface SpellCheckMethodHandler { + /** + * Requests that spell check is initiated for the specified text, which will respond to the + * {@code result} with either success if spell check results are received or error if the + * request is skipped. + */ + void initiateSpellCheck( + @NonNull String locale, @NonNull String text, @NonNull MethodChannel.Result result); + } +} diff --git a/shell/platform/android/io/flutter/plugin/editing/SpellCheckPlugin.java b/shell/platform/android/io/flutter/plugin/editing/SpellCheckPlugin.java new file mode 100644 index 0000000000000..af024801748c6 --- /dev/null +++ b/shell/platform/android/io/flutter/plugin/editing/SpellCheckPlugin.java @@ -0,0 +1,158 @@ +// 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.editing; + +import android.view.textservice.SentenceSuggestionsInfo; +import android.view.textservice.SpellCheckerSession; +import android.view.textservice.SuggestionsInfo; +import android.view.textservice.TextInfo; +import android.view.textservice.TextServicesManager; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import io.flutter.embedding.engine.systemchannels.SpellCheckChannel; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.localization.LocalizationPlugin; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; + +/** + * {@link SpellCheckPlugin} is the implementation of all functionality needed for spell check for + * text input. + * + *

The plugin handles requests for spell check sent by the {@link + * io.flutter.embedding.engine.systemchannels.SpellCheckChannel} via sending requests to the Android + * spell checker. It also receives the spell check results from the service and sends them back to + * the framework through the {@link io.flutter.embedding.engine.systemchannels.SpellCheckChannel}. + */ +public class SpellCheckPlugin + implements SpellCheckChannel.SpellCheckMethodHandler, + SpellCheckerSession.SpellCheckerSessionListener { + + private final SpellCheckChannel mSpellCheckChannel; + private final TextServicesManager mTextServicesManager; + private SpellCheckerSession mSpellCheckerSession; + + @VisibleForTesting MethodChannel.Result pendingResult; + @VisibleForTesting String pendingResultText; + + // The maximum number of suggestions that the Android spell check service is allowed to provide + // per word. Same number that is used by default for Android's TextViews. + private static final int MAX_SPELL_CHECK_SUGGESTIONS = 5; + + public SpellCheckPlugin( + @NonNull TextServicesManager textServicesManager, + @NonNull SpellCheckChannel spellCheckChannel) { + mTextServicesManager = textServicesManager; + mSpellCheckChannel = spellCheckChannel; + + mSpellCheckChannel.setSpellCheckMethodHandler(this); + } + + /** + * Unregisters this {@code SpellCheckPlugin} as the {@code + * SpellCheckChannel.SpellCheckMethodHandler}, for the {@link + * io.flutter.embedding.engine.systemchannels.SpellCheckChannel}, and closes the most recently + * opened {@code SpellCheckerSession}. + * + *

Do not invoke any methods on a {@code SpellCheckPlugin} after invoking this method. + */ + public void destroy() { + mSpellCheckChannel.setSpellCheckMethodHandler(null); + + if (mSpellCheckerSession != null) { + mSpellCheckerSession.close(); + } + } + + /** + * Initiates call to native spell checker to spell check specified text if there is no result + * awaiting a response. + */ + @Override + public void initiateSpellCheck( + @NonNull String locale, @NonNull String text, @NonNull MethodChannel.Result result) { + if (pendingResult != null) { + result.error("error", "Previous spell check request still pending.", null); + return; + } + + pendingResult = result; + pendingResultText = text; + + performSpellCheck(locale, text); + } + + /** Calls on the Android spell check API to spell check specified text. */ + public void performSpellCheck(@NonNull String locale, @NonNull String text) { + String[] localeCodes = locale.split("-"); + Locale localeFromString = LocalizationPlugin.localeFromString(locale); + + if (mSpellCheckerSession == null) { + mSpellCheckerSession = + mTextServicesManager.newSpellCheckerSession( + null, + localeFromString, + this, + /** referToSpellCheckerLanguageSettings= */ + true); + } + + TextInfo[] textInfos = new TextInfo[] {new TextInfo(text)}; + mSpellCheckerSession.getSentenceSuggestions(textInfos, MAX_SPELL_CHECK_SUGGESTIONS); + } + + /** + * Callback for Android spell check API that decomposes results and send results through the + * {@link SpellCheckChannel}. + * + *

Spell check results will be encoded as a string representing the span of that result, with + * the format "start_index.end_index.suggestion_1/nsuggestion_2/nsuggestion_3", where there may be + * up to 5 suggestions. + */ + @Override + public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results) { + if (results.length == 0) { + pendingResult.success(new ArrayList<>(Arrays.asList(pendingResultText, ""))); + pendingResult = null; + return; + } + + ArrayList spellCheckerSuggestionSpans = new ArrayList(); + SentenceSuggestionsInfo spellCheckResults = results[0]; + + for (int i = 0; i < spellCheckResults.getSuggestionsCount(); i++) { + SuggestionsInfo suggestionsInfo = spellCheckResults.getSuggestionsInfoAt(i); + int suggestionsCount = suggestionsInfo.getSuggestionsCount(); + + if (suggestionsCount <= 0) { + continue; + } + + String spellCheckerSuggestionSpan = ""; + int start = spellCheckResults.getOffsetAt(i); + int end = start + spellCheckResults.getLengthAt(i) - 1; + + spellCheckerSuggestionSpan += String.valueOf(start) + "."; + spellCheckerSuggestionSpan += String.valueOf(end) + "."; + + for (int j = 0; j < suggestionsCount; j++) { + spellCheckerSuggestionSpan += suggestionsInfo.getSuggestionAt(j) + "\n"; + } + + spellCheckerSuggestionSpans.add( + spellCheckerSuggestionSpan.substring(0, spellCheckerSuggestionSpan.length() - 1)); + } + + spellCheckerSuggestionSpans.add(0, pendingResultText); + pendingResult.success(spellCheckerSuggestionSpans); + pendingResult = null; + } + + @Override + public void onGetSuggestions(SuggestionsInfo[] results) { + // Deprecated callback for Android spell check API; will not use. + } +} diff --git a/shell/platform/android/test/io/flutter/CustomShadowContextImpl.java b/shell/platform/android/test/io/flutter/CustomShadowContextImpl.java new file mode 100644 index 0000000000000..feab3607987be --- /dev/null +++ b/shell/platform/android/test/io/flutter/CustomShadowContextImpl.java @@ -0,0 +1,20 @@ +package io.flutter; + +import android.content.Context; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowContextImpl; + +@Implements(className = ShadowContextImpl.CLASS_NAME) +public class CustomShadowContextImpl extends ShadowContextImpl { + public static final String CLASS_NAME = "android.app.ContextImpl"; + + @Implementation + @Override + public final Object getSystemService(String name) { + if (name == Context.TEXT_SERVICES_MANAGER_SERVICE) { + return null; + } + return super.getSystemService(name); + } +} diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java index 9cb08f8324d4f..5c3d50224db33 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java @@ -1100,6 +1100,8 @@ private FlutterEngine mockFlutterEngine() { when(fakeMessageBuilder.setPlatformBrightness(any(SettingsChannel.PlatformBrightness.class))) .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setTextScaleFactor(any(Float.class))).thenReturn(fakeMessageBuilder); + when(fakeMessageBuilder.setNativeSpellCheckServiceDefined(any(Boolean.class))) + .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setBrieflyShowPassword(any(Boolean.class))) .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setUse24HourFormat(any(Boolean.class))).thenReturn(fakeMessageBuilder); diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java index 52d02eaa3096f..91b9244e4d800 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java @@ -206,6 +206,8 @@ public void itSendsLightPlatformBrightnessToFlutter() { SettingsChannel fakeSettingsChannel = mock(SettingsChannel.class); SettingsChannel.MessageBuilder fakeMessageBuilder = mock(SettingsChannel.MessageBuilder.class); when(fakeMessageBuilder.setTextScaleFactor(any(Float.class))).thenReturn(fakeMessageBuilder); + when(fakeMessageBuilder.setNativeSpellCheckServiceDefined(any(Boolean.class))) + .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setBrieflyShowPassword(any(Boolean.class))) .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setUse24HourFormat(any(Boolean.class))).thenReturn(fakeMessageBuilder); @@ -256,6 +258,8 @@ public void itSendsDarkPlatformBrightnessToFlutter() { SettingsChannel fakeSettingsChannel = mock(SettingsChannel.class); SettingsChannel.MessageBuilder fakeMessageBuilder = mock(SettingsChannel.MessageBuilder.class); when(fakeMessageBuilder.setTextScaleFactor(any(Float.class))).thenReturn(fakeMessageBuilder); + when(fakeMessageBuilder.setNativeSpellCheckServiceDefined(any(Boolean.class))) + .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setBrieflyShowPassword(any(Boolean.class))) .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setUse24HourFormat(any(Boolean.class))).thenReturn(fakeMessageBuilder); @@ -294,6 +298,8 @@ public void itSendsTextShowPasswordToFrameworkOnAttach() { SettingsChannel fakeSettingsChannel = mock(SettingsChannel.class); SettingsChannel.MessageBuilder fakeMessageBuilder = mock(SettingsChannel.MessageBuilder.class); when(fakeMessageBuilder.setTextScaleFactor(any(Float.class))).thenReturn(fakeMessageBuilder); + when(fakeMessageBuilder.setNativeSpellCheckServiceDefined(any(Boolean.class))) + .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setPlatformBrightness(any(SettingsChannel.PlatformBrightness.class))) .thenReturn(fakeMessageBuilder); when(fakeMessageBuilder.setUse24HourFormat(any(Boolean.class))).thenReturn(fakeMessageBuilder); diff --git a/shell/platform/android/test/io/flutter/plugin/editing/SpellCheckPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/SpellCheckPluginTest.java new file mode 100644 index 0000000000000..42a8f97d1874a --- /dev/null +++ b/shell/platform/android/test/io/flutter/plugin/editing/SpellCheckPluginTest.java @@ -0,0 +1,217 @@ +package io.flutter.plugin.editing; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.view.textservice.SentenceSuggestionsInfo; +import android.view.textservice.SpellCheckerSession; +import android.view.textservice.SuggestionsInfo; +import android.view.textservice.TextInfo; +import android.view.textservice.TextServicesManager; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import io.flutter.embedding.engine.dart.DartExecutor; +import io.flutter.embedding.engine.systemchannels.SpellCheckChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.JSONMethodCodec; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +@RunWith(AndroidJUnit4.class) +public class SpellCheckPluginTest { + + private static void sendToBinaryMessageHandler( + BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method, Object args) { + MethodCall methodCall = new MethodCall(method, args); + ByteBuffer encodedMethodCall = JSONMethodCodec.INSTANCE.encodeMethodCall(methodCall); + binaryMessageHandler.onMessage( + (ByteBuffer) encodedMethodCall.flip(), mock(BinaryMessenger.BinaryReply.class)); + } + + @Test + public void respondsToSpellCheckChannelMessage() { + ArgumentCaptor binaryMessageHandlerCaptor = + ArgumentCaptor.forClass(BinaryMessenger.BinaryMessageHandler.class); + DartExecutor mockBinaryMessenger = mock(DartExecutor.class); + SpellCheckChannel.SpellCheckMethodHandler mockHandler = + mock(SpellCheckChannel.SpellCheckMethodHandler.class); + SpellCheckChannel spellCheckChannel = new SpellCheckChannel(mockBinaryMessenger); + + spellCheckChannel.setSpellCheckMethodHandler(mockHandler); + + verify(mockBinaryMessenger, times(1)) + .setMessageHandler(any(String.class), binaryMessageHandlerCaptor.capture()); + + BinaryMessenger.BinaryMessageHandler binaryMessageHandler = + binaryMessageHandlerCaptor.getValue(); + + sendToBinaryMessageHandler( + binaryMessageHandler, + "SpellCheck.initiateSpellCheck", + Arrays.asList("en-US", "Hello, wrold!")); + + verify(mockHandler) + .initiateSpellCheck(eq("en-US"), eq("Hello, wrold!"), any(MethodChannel.Result.class)); + } + + @Test + public void initiateSpellCheckPerformsSpellCheckWhenNoResultPending() { + SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class); + TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class); + SpellCheckPlugin spellCheckPlugin = + spy(new SpellCheckPlugin(fakeTextServicesManager, fakeSpellCheckChannel)); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + SpellCheckerSession fakeSpellCheckerSession = mock(SpellCheckerSession.class); + + when(fakeTextServicesManager.newSpellCheckerSession( + null, new Locale("en", "US"), spellCheckPlugin, true)) + .thenReturn(fakeSpellCheckerSession); + + spellCheckPlugin.initiateSpellCheck("en-US", "Hello, wrold!", mockResult); + + verify(spellCheckPlugin).performSpellCheck("en-US", "Hello, wrold!"); + } + + @Test + public void initiateSpellCheckThrowsErrorWhenResultPending() { + SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class); + TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class); + SpellCheckPlugin spellCheckPlugin = + spy(new SpellCheckPlugin(fakeTextServicesManager, fakeSpellCheckChannel)); + MethodChannel.Result mockPendingResult = mock(MethodChannel.Result.class); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + spellCheckPlugin.pendingResult = mockPendingResult; + + spellCheckPlugin.initiateSpellCheck("en-US", "Hello, wrold!", mockResult); + + verify(mockResult).error("error", "Previous spell check request still pending.", null); + verify(spellCheckPlugin, never()).performSpellCheck("en-US", "Hello, wrold!"); + } + + @Test + public void destroyClosesSpellCheckerSessionAndClearsSpellCheckMethodHandler() { + Context fakeContext = mock(Context.class); + SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class); + TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class); + when(fakeContext.getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE)) + .thenReturn(fakeTextServicesManager); + SpellCheckPlugin spellCheckPlugin = + spy(new SpellCheckPlugin(fakeTextServicesManager, fakeSpellCheckChannel)); + SpellCheckerSession fakeSpellCheckerSession = mock(SpellCheckerSession.class); + + when(fakeTextServicesManager.newSpellCheckerSession( + null, new Locale("en", "US"), spellCheckPlugin, true)) + .thenReturn(fakeSpellCheckerSession); + + spellCheckPlugin.performSpellCheck("en-US", "Hello, wrold!"); + spellCheckPlugin.destroy(); + + verify(fakeSpellCheckChannel).setSpellCheckMethodHandler(isNull()); + verify(fakeSpellCheckerSession).close(); + } + + @Test + public void performSpellCheckSendsRequestToAndroidSpellCheckService() { + Context fakeContext = mock(Context.class); + SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class); + TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class); + when(fakeContext.getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE)) + .thenReturn(fakeTextServicesManager); + SpellCheckPlugin spellCheckPlugin = + spy(new SpellCheckPlugin(fakeTextServicesManager, fakeSpellCheckChannel)); + SpellCheckerSession fakeSpellCheckerSession = mock(SpellCheckerSession.class); + Locale english_US = new Locale("en", "US"); + + when(fakeTextServicesManager.newSpellCheckerSession(null, english_US, spellCheckPlugin, true)) + .thenReturn(fakeSpellCheckerSession); + + int maxSuggestions = 5; + ArgumentCaptor textInfosCaptor = ArgumentCaptor.forClass(TextInfo[].class); + ArgumentCaptor maxSuggestionsCaptor = ArgumentCaptor.forClass(Integer.class); + + spellCheckPlugin.performSpellCheck("en-US", "Hello, wrold!"); + + verify(fakeSpellCheckerSession) + .getSentenceSuggestions(textInfosCaptor.capture(), maxSuggestionsCaptor.capture()); + assertEquals("Hello, wrold!", textInfosCaptor.getValue()[0].getText()); + assertEquals(Integer.valueOf(maxSuggestions), maxSuggestionsCaptor.getValue()); + } + + @Test + public void performSpellCheckCreatesNewSpellCheckerSession() { + Context fakeContext = mock(Context.class); + SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class); + TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class); + when(fakeContext.getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE)) + .thenReturn(fakeTextServicesManager); + SpellCheckPlugin spellCheckPlugin = + spy(new SpellCheckPlugin(fakeTextServicesManager, fakeSpellCheckChannel)); + SpellCheckerSession fakeSpellCheckerSession = mock(SpellCheckerSession.class); + Locale english_US = new Locale("en", "US"); + + when(fakeTextServicesManager.newSpellCheckerSession(null, english_US, spellCheckPlugin, true)) + .thenReturn(fakeSpellCheckerSession); + + spellCheckPlugin.performSpellCheck("en-US", "Hello, worl!"); + spellCheckPlugin.performSpellCheck("en-US", "Hello, world!"); + + verify(fakeTextServicesManager, times(1)) + .newSpellCheckerSession(null, english_US, spellCheckPlugin, true); + } + + @Test + public void onGetSentenceSuggestionsResultsWithSuccessAndNoResultsProperly() { + TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class); + SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class); + SpellCheckPlugin spellCheckPlugin = + spy(new SpellCheckPlugin(fakeTextServicesManager, fakeSpellCheckChannel)); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + spellCheckPlugin.pendingResult = mockResult; + spellCheckPlugin.pendingResultText = "Hello, world!"; + + spellCheckPlugin.onGetSentenceSuggestions(new SentenceSuggestionsInfo[] {}); + + verify(mockResult).success(new ArrayList(Arrays.asList("Hello, world!", ""))); + } + + @Test + public void onGetSentenceSuggestionsResultsWithSuccessAndResultsProperly() { + TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class); + SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class); + SpellCheckPlugin spellCheckPlugin = + spy(new SpellCheckPlugin(fakeTextServicesManager, fakeSpellCheckChannel)); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + spellCheckPlugin.pendingResult = mockResult; + spellCheckPlugin.pendingResultText = "Hello, wrold!"; + + spellCheckPlugin.onGetSentenceSuggestions( + new SentenceSuggestionsInfo[] { + new SentenceSuggestionsInfo( + (new SuggestionsInfo[] { + new SuggestionsInfo( + SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO, + new String[] {"world", "word", "old"}) + }), + new int[] {7}, + new int[] {5}) + }); + + verify(mockResult) + .success(new ArrayList(Arrays.asList("Hello, wrold!", "7.11.world\nword\nold"))); + } +} diff --git a/shell/platform/android/test_runner/src/main/resources/robolectric.properties b/shell/platform/android/test_runner/src/main/resources/robolectric.properties index ffcbe2dd23944..ba72a7e57eb78 100644 --- a/shell/platform/android/test_runner/src/main/resources/robolectric.properties +++ b/shell/platform/android/test_runner/src/main/resources/robolectric.properties @@ -1 +1,2 @@ sdk=31 +shadows=io.flutter.CustomShadowContextImpl