Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit c4c6ef6

Browse files
authored
Samsung keyboard duplication workaround: updateSelection (#16547)
1 parent 42f18d9 commit c4c6ef6

File tree

2 files changed

+140
-8
lines changed

2 files changed

+140
-8
lines changed

shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
package io.flutter.plugin.editing;
66

7+
import android.annotation.SuppressLint;
78
import android.content.Context;
89
import android.os.Build;
10+
import android.provider.Settings;
911
import android.text.DynamicLayout;
1012
import android.text.Editable;
1113
import android.text.Layout;
@@ -17,6 +19,7 @@
1719
import android.view.inputmethod.CursorAnchorInfo;
1820
import android.view.inputmethod.EditorInfo;
1921
import android.view.inputmethod.InputMethodManager;
22+
import android.view.inputmethod.InputMethodSubtype;
2023
import io.flutter.Log;
2124
import io.flutter.embedding.engine.systemchannels.TextInputChannel;
2225

@@ -30,6 +33,9 @@ class InputConnectionAdaptor extends BaseInputConnection {
3033
private InputMethodManager mImm;
3134
private final Layout mLayout;
3235

36+
// Used to determine if Samsung-specific hacks should be applied.
37+
private final boolean isSamsung;
38+
3339
@SuppressWarnings("deprecation")
3440
public InputConnectionAdaptor(
3541
View view,
@@ -56,6 +62,8 @@ public InputConnectionAdaptor(
5662
0.0f,
5763
false);
5864
mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
65+
66+
isSamsung = isSamsung();
5967
}
6068

6169
// Send the current state of the editable to Flutter.
@@ -132,19 +140,64 @@ public boolean setComposingText(CharSequence text, int newCursorPosition) {
132140
public boolean finishComposingText() {
133141
boolean result = super.finishComposingText();
134142

135-
if (Build.VERSION.SDK_INT >= 21) {
136-
// Update the keyboard with a reset/empty composing region. Critical on
137-
// Samsung keyboards to prevent punctuation duplication.
138-
CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
139-
builder.setComposingText(-1, "");
140-
CursorAnchorInfo anchorInfo = builder.build();
141-
mImm.updateCursorAnchorInfo(mFlutterView, anchorInfo);
143+
// Apply Samsung hacks. Samsung caches composing region data strangely, causing text
144+
// duplication.
145+
if (isSamsung) {
146+
if (Build.VERSION.SDK_INT >= 21) {
147+
// Samsung keyboards don't clear the composing region on finishComposingText.
148+
// Update the keyboard with a reset/empty composing region. Critical on
149+
// Samsung keyboards to prevent punctuation duplication.
150+
CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
151+
builder.setComposingText(/*composingTextStart*/ -1, /*composingText*/ "");
152+
CursorAnchorInfo anchorInfo = builder.build();
153+
mImm.updateCursorAnchorInfo(mFlutterView, anchorInfo);
154+
}
155+
// TODO(garyq): There is still a duplication case that comes from hiding+showing the keyboard.
156+
// The exact behavior to cause it has so far been hard to pinpoint and it happens far more
157+
// rarely than the original bug.
158+
159+
// Temporarily indicate to the IME that the composing region selection should be reset.
160+
// The correct selection is then immediately set properly in the updateEditingState() call
161+
// in this method. This is a hack to trigger Samsung keyboard's internal cache to clear.
162+
// This prevents duplication on keyboard hide+show. See
163+
// https://github.com/flutter/flutter/issues/31512
164+
//
165+
// We only do this if the proper selection will be restored later, eg, when mBatchCount is 0.
166+
if (mBatchCount == 0) {
167+
mImm.updateSelection(
168+
mFlutterView,
169+
-1, /*selStart*/
170+
-1, /*selEnd*/
171+
-1, /*candidatesStart*/
172+
-1 /*candidatesEnd*/);
173+
}
142174
}
143175

144176
updateEditingState();
145177
return result;
146178
}
147179

180+
// Detect if the keyboard is a Samsung keyboard, where we apply Samsung-specific hacks to
181+
// fix critical bugs that make the keyboard otherwise unusable. See finishComposingText() for
182+
// more details.
183+
@SuppressLint("NewApi") // New API guard is inline, the linter can't see it.
184+
@SuppressWarnings("deprecation")
185+
private boolean isSamsung() {
186+
InputMethodSubtype subtype = mImm.getCurrentInputMethodSubtype();
187+
// Impacted devices all shipped with Android Lollipop or newer.
188+
if (subtype == null
189+
|| Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
190+
|| !Build.MANUFACTURER.equals("samsung")) {
191+
return false;
192+
}
193+
String keyboardName =
194+
Settings.Secure.getString(
195+
mFlutterView.getContext().getContentResolver(), Settings.Secure.DEFAULT_INPUT_METHOD);
196+
// The Samsung keyboard is called "com.sec.android.inputmethod/.SamsungKeypad" but look
197+
// for "Samsung" just in case Samsung changes the name of the keyboard.
198+
return keyboardName.contains("Samsung");
199+
}
200+
148201
@Override
149202
public boolean setSelection(int start, int end) {
150203
boolean result = super.setSelection(start, end);

shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
import io.flutter.plugin.common.MethodCall;
2929
import io.flutter.plugin.platform.PlatformViewsController;
3030
import java.nio.ByteBuffer;
31+
import java.util.ArrayList;
32+
import java.util.Arrays;
33+
import java.util.List;
3134
import org.json.JSONArray;
3235
import org.json.JSONException;
3336
import org.junit.Test;
@@ -305,9 +308,17 @@ public void inputConnection_createsActionFromEnter() throws JSONException {
305308

306309
@Test
307310
public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException {
311+
ShadowBuild.setManufacturer("samsung");
312+
InputMethodSubtype inputMethodSubtype =
313+
new InputMethodSubtype(0, 0, /*locale=*/ "en", "", "", false, false);
314+
Settings.Secure.putString(
315+
RuntimeEnvironment.application.getContentResolver(),
316+
Settings.Secure.DEFAULT_INPUT_METHOD,
317+
"com.sec.android.inputmethod/.SamsungKeypad");
308318
TestImm testImm =
309319
Shadow.extract(
310320
RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE));
321+
testImm.setCurrentInputMethodSubtype(inputMethodSubtype);
311322
FlutterJNI mockFlutterJni = mock(FlutterJNI.class);
312323
View testView = new View(RuntimeEnvironment.application);
313324
DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class)));
@@ -338,13 +349,59 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException
338349
}
339350
}
340351

352+
@Test
353+
public void inputConnection_samsungFinishComposingTextSetsSelection() throws JSONException {
354+
ShadowBuild.setManufacturer("samsung");
355+
InputMethodSubtype inputMethodSubtype =
356+
new InputMethodSubtype(0, 0, /*locale=*/ "en", "", "", false, false);
357+
Settings.Secure.putString(
358+
RuntimeEnvironment.application.getContentResolver(),
359+
Settings.Secure.DEFAULT_INPUT_METHOD,
360+
"com.sec.android.inputmethod/.SamsungKeypad");
361+
TestImm testImm =
362+
Shadow.extract(
363+
RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE));
364+
testImm.setCurrentInputMethodSubtype(inputMethodSubtype);
365+
FlutterJNI mockFlutterJni = mock(FlutterJNI.class);
366+
View testView = new View(RuntimeEnvironment.application);
367+
DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class)));
368+
TextInputPlugin textInputPlugin =
369+
new TextInputPlugin(testView, dartExecutor, mock(PlatformViewsController.class));
370+
textInputPlugin.setTextInputClient(
371+
0,
372+
new TextInputChannel.Configuration(
373+
false,
374+
false,
375+
true,
376+
TextInputChannel.TextCapitalization.NONE,
377+
new TextInputChannel.InputType(TextInputChannel.TextInputType.TEXT, false, false),
378+
null,
379+
null));
380+
// There's a pending restart since we initialized the text input client. Flush that now.
381+
textInputPlugin.setTextInputEditingState(
382+
testView, new TextInputChannel.TextEditState("", 0, 0));
383+
InputConnection connection = textInputPlugin.createInputConnection(testView, new EditorInfo());
384+
385+
testImm.setTrackSelection(true);
386+
connection.finishComposingText();
387+
testImm.setTrackSelection(false);
388+
389+
List<Integer> expectedSelectionValues =
390+
Arrays.asList(0, 0, -1, -1, -1, -1, -1, -1, 0, 0, -1, -1);
391+
assertEquals(testImm.getSelectionUpdateValues(), expectedSelectionValues);
392+
}
393+
341394
@Implements(InputMethodManager.class)
342395
public static class TestImm extends ShadowInputMethodManager {
343396
private InputMethodSubtype currentInputMethodSubtype;
344397
private SparseIntArray restartCounter = new SparseIntArray();
345398
private CursorAnchorInfo cursorAnchorInfo;
399+
private ArrayList<Integer> selectionUpdateValues;
400+
private boolean trackSelection = false;
346401

347-
public TestImm() {}
402+
public TestImm() {
403+
selectionUpdateValues = new ArrayList<Integer>();
404+
}
348405

349406
@Implementation
350407
public InputMethodSubtype getCurrentInputMethodSubtype() {
@@ -370,6 +427,28 @@ public void updateCursorAnchorInfo(View view, CursorAnchorInfo cursorAnchorInfo)
370427
this.cursorAnchorInfo = cursorAnchorInfo;
371428
}
372429

430+
// We simply store the values to verify later.
431+
@Implementation
432+
public void updateSelection(
433+
View view, int selStart, int selEnd, int candidatesStart, int candidatesEnd) {
434+
if (trackSelection) {
435+
this.selectionUpdateValues.add(selStart);
436+
this.selectionUpdateValues.add(selEnd);
437+
this.selectionUpdateValues.add(candidatesStart);
438+
this.selectionUpdateValues.add(candidatesEnd);
439+
}
440+
}
441+
442+
// only track values when enabled via this.
443+
public void setTrackSelection(boolean val) {
444+
trackSelection = val;
445+
}
446+
447+
// Returns true if the last updateSelection call passed the following values.
448+
public ArrayList<Integer> getSelectionUpdateValues() {
449+
return selectionUpdateValues;
450+
}
451+
373452
public CursorAnchorInfo getLastCursorAnchorInfo() {
374453
return cursorAnchorInfo;
375454
}

0 commit comments

Comments
 (0)