Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe lead with "Samsung keyboards don't clear the composing region on finishComposingText. [... current text ...]

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hiding and showing the keyboard can restart the IMM. May be related?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have a solid answer here, but in my experimentation, restarting IMM did not seem to have any noticeable effect on this behavior.

// 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;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume if they fix this bug in a future release, we'll need to change this logic. Is there no way we can narrow this down further?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this hack should be fully compatible with other keyboards. Anything we do here is not 1: telling the keyboard something that it should already know, or 2: performing an action that we are not guaranteed to immediately revert.

It seems unlikely to me that Samsung will release a fix for all versions as well as distribute to the hundreds of millions of devices in the wild.

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