Skip to content

Commit

Permalink
Samsung keyboard duplication workaround: updateSelection (#16547)
Browse files Browse the repository at this point in the history
  • Loading branch information
GaryQian committed Feb 13, 2020
1 parent 42f18d9 commit c4c6ef6
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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<Integer> 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<Integer> selectionUpdateValues;
private boolean trackSelection = false;

public TestImm() {}
public TestImm() {
selectionUpdateValues = new ArrayList<Integer>();
}

@Implementation
public InputMethodSubtype getCurrentInputMethodSubtype() {
Expand All @@ -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<Integer> getSelectionUpdateValues() {
return selectionUpdateValues;
}

public CursorAnchorInfo getLastCursorAnchorInfo() {
return cursorAnchorInfo;
}
Expand Down

0 comments on commit c4c6ef6

Please sign in to comment.