diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 64e41df5f0047..20b11c68024f4 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -4,8 +4,10 @@ package io.flutter.plugin.editing; +import android.annotation.SuppressLint; import android.content.Context; import android.os.Build; +import android.provider.Settings; import android.text.DynamicLayout; import android.text.Editable; import android.text.Layout; @@ -17,6 +19,7 @@ import android.view.inputmethod.CursorAnchorInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; +import android.view.inputmethod.InputMethodSubtype; import io.flutter.Log; import io.flutter.embedding.engine.systemchannels.TextInputChannel; @@ -30,6 +33,9 @@ class InputConnectionAdaptor extends BaseInputConnection { private InputMethodManager mImm; private final Layout mLayout; + // Used to determine if Samsung-specific hacks should be applied. + private final boolean isSamsung; + @SuppressWarnings("deprecation") public InputConnectionAdaptor( View view, @@ -56,6 +62,8 @@ public InputConnectionAdaptor( 0.0f, false); mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + + isSamsung = isSamsung(); } // Send the current state of the editable to Flutter. @@ -132,19 +140,64 @@ public boolean setComposingText(CharSequence text, int newCursorPosition) { public boolean finishComposingText() { boolean result = super.finishComposingText(); - if (Build.VERSION.SDK_INT >= 21) { - // Update the keyboard with a reset/empty composing region. Critical on - // Samsung keyboards to prevent punctuation duplication. - CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder(); - builder.setComposingText(-1, ""); - CursorAnchorInfo anchorInfo = builder.build(); - mImm.updateCursorAnchorInfo(mFlutterView, anchorInfo); + // Apply Samsung hacks. Samsung caches composing region data strangely, causing text + // duplication. + if (isSamsung) { + if (Build.VERSION.SDK_INT >= 21) { + // Samsung keyboards don't clear the composing region on finishComposingText. + // Update the keyboard with a reset/empty composing region. Critical on + // Samsung keyboards to prevent punctuation duplication. + CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder(); + builder.setComposingText(/*composingTextStart*/ -1, /*composingText*/ ""); + CursorAnchorInfo anchorInfo = builder.build(); + mImm.updateCursorAnchorInfo(mFlutterView, anchorInfo); + } + // TODO(garyq): There is still a duplication case that comes from hiding+showing the keyboard. + // The exact behavior to cause it has so far been hard to pinpoint and it happens far more + // rarely than the original bug. + + // Temporarily indicate to the IME that the composing region selection should be reset. + // The correct selection is then immediately set properly in the updateEditingState() call + // in this method. This is a hack to trigger Samsung keyboard's internal cache to clear. + // This prevents duplication on keyboard hide+show. See + // https://github.com/flutter/flutter/issues/31512 + // + // We only do this if the proper selection will be restored later, eg, when mBatchCount is 0. + if (mBatchCount == 0) { + mImm.updateSelection( + mFlutterView, + -1, /*selStart*/ + -1, /*selEnd*/ + -1, /*candidatesStart*/ + -1 /*candidatesEnd*/); + } } updateEditingState(); return result; } + // Detect if the keyboard is a Samsung keyboard, where we apply Samsung-specific hacks to + // fix critical bugs that make the keyboard otherwise unusable. See finishComposingText() for + // more details. + @SuppressLint("NewApi") // New API guard is inline, the linter can't see it. + @SuppressWarnings("deprecation") + private boolean isSamsung() { + InputMethodSubtype subtype = mImm.getCurrentInputMethodSubtype(); + // Impacted devices all shipped with Android Lollipop or newer. + if (subtype == null + || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP + || !Build.MANUFACTURER.equals("samsung")) { + return false; + } + String keyboardName = + Settings.Secure.getString( + mFlutterView.getContext().getContentResolver(), Settings.Secure.DEFAULT_INPUT_METHOD); + // The Samsung keyboard is called "com.sec.android.inputmethod/.SamsungKeypad" but look + // for "Samsung" just in case Samsung changes the name of the keyboard. + return keyboardName.contains("Samsung"); + } + @Override public boolean setSelection(int start, int end) { boolean result = super.setSelection(start, end); diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index bb0a8ff9fa7ea..51dbc603b9793 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -28,6 +28,9 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.platform.PlatformViewsController; import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import org.json.JSONArray; import org.json.JSONException; import org.junit.Test; @@ -305,9 +308,17 @@ public void inputConnection_createsActionFromEnter() throws JSONException { @Test public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException { + ShadowBuild.setManufacturer("samsung"); + InputMethodSubtype inputMethodSubtype = + new InputMethodSubtype(0, 0, /*locale=*/ "en", "", "", false, false); + Settings.Secure.putString( + RuntimeEnvironment.application.getContentResolver(), + Settings.Secure.DEFAULT_INPUT_METHOD, + "com.sec.android.inputmethod/.SamsungKeypad"); TestImm testImm = Shadow.extract( RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE)); + testImm.setCurrentInputMethodSubtype(inputMethodSubtype); FlutterJNI mockFlutterJni = mock(FlutterJNI.class); View testView = new View(RuntimeEnvironment.application); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); @@ -338,13 +349,59 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException } } + @Test + public void inputConnection_samsungFinishComposingTextSetsSelection() throws JSONException { + ShadowBuild.setManufacturer("samsung"); + InputMethodSubtype inputMethodSubtype = + new InputMethodSubtype(0, 0, /*locale=*/ "en", "", "", false, false); + Settings.Secure.putString( + RuntimeEnvironment.application.getContentResolver(), + Settings.Secure.DEFAULT_INPUT_METHOD, + "com.sec.android.inputmethod/.SamsungKeypad"); + TestImm testImm = + Shadow.extract( + RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE)); + testImm.setCurrentInputMethodSubtype(inputMethodSubtype); + FlutterJNI mockFlutterJni = mock(FlutterJNI.class); + View testView = new View(RuntimeEnvironment.application); + DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, dartExecutor, mock(PlatformViewsController.class)); + textInputPlugin.setTextInputClient( + 0, + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + new TextInputChannel.InputType(TextInputChannel.TextInputType.TEXT, false, false), + null, + null)); + // There's a pending restart since we initialized the text input client. Flush that now. + textInputPlugin.setTextInputEditingState( + testView, new TextInputChannel.TextEditState("", 0, 0)); + InputConnection connection = textInputPlugin.createInputConnection(testView, new EditorInfo()); + + testImm.setTrackSelection(true); + connection.finishComposingText(); + testImm.setTrackSelection(false); + + List expectedSelectionValues = + Arrays.asList(0, 0, -1, -1, -1, -1, -1, -1, 0, 0, -1, -1); + assertEquals(testImm.getSelectionUpdateValues(), expectedSelectionValues); + } + @Implements(InputMethodManager.class) public static class TestImm extends ShadowInputMethodManager { private InputMethodSubtype currentInputMethodSubtype; private SparseIntArray restartCounter = new SparseIntArray(); private CursorAnchorInfo cursorAnchorInfo; + private ArrayList selectionUpdateValues; + private boolean trackSelection = false; - public TestImm() {} + public TestImm() { + selectionUpdateValues = new ArrayList(); + } @Implementation public InputMethodSubtype getCurrentInputMethodSubtype() { @@ -370,6 +427,28 @@ public void updateCursorAnchorInfo(View view, CursorAnchorInfo cursorAnchorInfo) this.cursorAnchorInfo = cursorAnchorInfo; } + // We simply store the values to verify later. + @Implementation + public void updateSelection( + View view, int selStart, int selEnd, int candidatesStart, int candidatesEnd) { + if (trackSelection) { + this.selectionUpdateValues.add(selStart); + this.selectionUpdateValues.add(selEnd); + this.selectionUpdateValues.add(candidatesStart); + this.selectionUpdateValues.add(candidatesEnd); + } + } + + // only track values when enabled via this. + public void setTrackSelection(boolean val) { + trackSelection = val; + } + + // Returns true if the last updateSelection call passed the following values. + public ArrayList getSelectionUpdateValues() { + return selectionUpdateValues; + } + public CursorAnchorInfo getLastCursorAnchorInfo() { return cursorAnchorInfo; }