From 592f1619b0a5c190102af188410dc384737dcb2a Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 29 Sep 2020 22:42:17 -0700 Subject: [PATCH 01/18] wip --- .../editing/InputConnectionAdaptor.java | 30 +++++-------------- .../plugin/editing/TextInputPlugin.java | 15 ++++++---- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 26cb5d9d984e7..26e1881d607d9 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -45,8 +45,7 @@ class InputConnectionAdaptor extends BaseInputConnection { // Used to determine if Samsung-specific hacks should be applied. private final boolean isSamsung; - private boolean mRepeatCheckNeeded = false; - private TextEditingValue mLastSentTextEditngValue; + private TextEditingValue mLastKnownTextEditingValue; // Data class used to get and store the last-sent values via updateEditingState to // the framework. These are then compared against to prevent redundant messages // with the same data before any valid operations were made to the contents. @@ -149,7 +148,7 @@ private void updateEditingState() { // occurred to mark this as dirty. This prevents duplicate remote updates of // the same data, which can break formatters that change the length of the // contents. - if (mRepeatCheckNeeded && currentValue.equals(mLastSentTextEditngValue)) { + if (currentValue.equals(mLastKnownTextEditingValue)) { return; } @@ -168,17 +167,13 @@ private void updateEditingState() { currentValue.composingStart, currentValue.composingEnd); - mRepeatCheckNeeded = true; - mLastSentTextEditngValue = currentValue; + mLastKnownTextEditingValue = currentValue; } - // This should be called whenever a change could have been made to - // the value of mEditable, which will make any call of updateEditingState() - // ineligible for repeat checking as we do not want to skip sending real changes - // to the framework. - public void markDirty() { - // Disable updateEditngState's repeat-update check - mRepeatCheckNeeded = false; + // Called when the current text editing state held by the text input plugin is overwritten by a + // newly received value from the framework. + public void didUpdateEditingValue() { + mLastKnownTextEditingValue = new TextEditingValue(mEditable) ; } @Override @@ -203,7 +198,6 @@ public boolean endBatchEdit() { @Override public boolean commitText(CharSequence text, int newCursorPosition) { boolean result = super.commitText(text, newCursorPosition); - markDirty(); return result; } @@ -212,21 +206,18 @@ public boolean deleteSurroundingText(int beforeLength, int afterLength) { if (Selection.getSelectionStart(mEditable) == -1) return true; boolean result = super.deleteSurroundingText(beforeLength, afterLength); - markDirty(); return result; } @Override public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { boolean result = super.deleteSurroundingTextInCodePoints(beforeLength, afterLength); - markDirty(); return result; } @Override public boolean setComposingRegion(int start, int end) { boolean result = super.setComposingRegion(start, end); - markDirty(); return result; } @@ -238,7 +229,6 @@ public boolean setComposingText(CharSequence text, int newCursorPosition) { } else { result = super.setComposingText(text, newCursorPosition); } - markDirty(); return result; } @@ -260,7 +250,6 @@ public boolean finishComposingText() { } } - markDirty(); return result; } @@ -277,7 +266,6 @@ public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { @Override public boolean clearMetaKeyStates(int states) { boolean result = super.clearMetaKeyStates(states); - markDirty(); return result; } @@ -305,7 +293,6 @@ private boolean isSamsung() { @Override public boolean setSelection(int start, int end) { boolean result = super.setSelection(start, end); - markDirty(); updateEditingState(); return result; } @@ -336,7 +323,6 @@ public boolean sendKeyEvent(KeyEvent event) { return true; } - markDirty(); if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { int selStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable); @@ -440,7 +426,6 @@ public boolean sendKeyEvent(KeyEvent event) { @Override public boolean performContextMenuAction(int id) { - markDirty(); if (id == android.R.id.selectAll) { setSelection(0, mEditable.length()); return true; @@ -500,7 +485,6 @@ public boolean performPrivateCommand(String action, Bundle data) { @Override public boolean performEditorAction(int actionCode) { - markDirty(); switch (actionCode) { case EditorInfo.IME_ACTION_NONE: textInputChannel.newline(mClient); diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index abbece5439490..6a2d45df79205 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -607,14 +607,13 @@ void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { if (!state.text.equals(mEditable.toString())) { mEditable.replace(0, mEditable.length(), state.text); } + + // Notify the autofill manager of the value change. notifyValueChanged(mEditable.toString()); // Always apply state to selection which handles updating the selection if needed. applyStateToSelection(state); - InputConnection connection = getLastInputConnection(); - if (connection != null && connection instanceof InputConnectionAdaptor) { - ((InputConnectionAdaptor) connection).markDirty(); - } - // Use updateSelection to update imm on selection if it is not neccessary to restart. + + // Use updateSelection to update imm on selection if it is not necessary to restart. if (!restartAlwaysRequired && !mRestartInputPending) { mImm.updateSelection( mView, @@ -628,6 +627,12 @@ void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { mImm.restartInput(view); mRestartInputPending = false; } + + // Notify the connection adaptor that the last known remote editing state has been updated. + InputConnection connection = getLastInputConnection(); + if (connection != null && connection instanceof InputConnectionAdaptor) { + ((InputConnectionAdaptor) connection).didUpdateEditingValue(); + } } private interface MinMax { From fd88233316881fbb90c8bd97bed5d551410a5cd8 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 30 Sep 2020 14:58:44 -0700 Subject: [PATCH 02/18] add test --- .../editing/InputConnectionAdaptor.java | 2 +- .../editing/InputConnectionAdaptorTest.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 26e1881d607d9..93d20deee99f1 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -173,7 +173,7 @@ private void updateEditingState() { // Called when the current text editing state held by the text input plugin is overwritten by a // newly received value from the framework. public void didUpdateEditingValue() { - mLastKnownTextEditingValue = new TextEditingValue(mEditable) ; + mLastKnownTextEditingValue = new TextEditingValue(mEditable); } @Override diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index a2d8eb963a42c..5dc742f464f00 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -955,6 +955,41 @@ public void inputConnectionAdaptor_RepeatFilter() throws NullPointerException { assertEquals(textInputChannel.selectionEnd, 4); } + @Test + public void inputConnectionAdaptor_skipCallsAfterEditingValueUpdateFromFramework() { + View testView = new View(RuntimeEnvironment.application); + FlutterJNI mockFlutterJni = mock(FlutterJNI.class); + DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); + int inputTargetId = 0; + TestTextInputChannel textInputChannel = new TestTextInputChannel(dartExecutor); + AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); + Editable mEditable = Editable.Factory.getInstance().newEditable(""); + Editable spyEditable = spy(mEditable); + EditorInfo outAttrs = new EditorInfo(); + outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; + + InputConnectionAdaptor inputConnectionAdaptor = + new InputConnectionAdaptor( + testView, inputTargetId, textInputChannel, mockKeyProcessor, spyEditable, outAttrs); + + inputConnectionAdaptor.setComposingText("initial text", 1); + // Calls updateEditingState. + inputConnectionAdaptor.beginBatchEdit(); + inputConnectionAdaptor.endBatchEdit(); + // Do send update to the framework. + assertEquals(textInputChannel.updateEditingStateInvocations, 1); + + // Change the internal state and pretend this is from the framework. + inputConnectionAdaptor.setComposingText("updated text from the framework", 1); + inputConnectionAdaptor.didUpdateEditingValue(); + + // Calls updateEditingState. + inputConnectionAdaptor.beginBatchEdit(); + inputConnectionAdaptor.endBatchEdit(); + // Does not send update because it is the same as the state we just received. + assertEquals(textInputChannel.updateEditingStateInvocations, 1); + } + @Test public void testSendKeyEvent_delKeyDeletesBackward() { int selStart = 29; From 69f2ece24375c8855b63b1d45a9689a1f4ec3d7e Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 30 Sep 2020 19:11:56 -0700 Subject: [PATCH 03/18] add test --- .../editing/InputConnectionAdaptorTest.java | 15 ++++--- .../plugin/editing/TextInputPluginTest.java | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index 5dc742f464f00..273ef03680227 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -964,30 +964,35 @@ public void inputConnectionAdaptor_skipCallsAfterEditingValueUpdateFromFramework TestTextInputChannel textInputChannel = new TestTextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); Editable mEditable = Editable.Factory.getInstance().newEditable(""); - Editable spyEditable = spy(mEditable); EditorInfo outAttrs = new EditorInfo(); outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; InputConnectionAdaptor inputConnectionAdaptor = new InputConnectionAdaptor( - testView, inputTargetId, textInputChannel, mockKeyProcessor, spyEditable, outAttrs); + testView, inputTargetId, textInputChannel, mockKeyProcessor, mEditable, outAttrs); inputConnectionAdaptor.setComposingText("initial text", 1); + assertEquals(textInputChannel.text, "initial text"); // Calls updateEditingState. inputConnectionAdaptor.beginBatchEdit(); inputConnectionAdaptor.endBatchEdit(); + assertEquals(textInputChannel.text, "initial text"); // Do send update to the framework. assertEquals(textInputChannel.updateEditingStateInvocations, 1); // Change the internal state and pretend this is from the framework. - inputConnectionAdaptor.setComposingText("updated text from the framework", 1); + mEditable.replace(0, mEditable.length(), "updated text from the framework"); inputConnectionAdaptor.didUpdateEditingValue(); + // Does not send update because it is the same as the state we just received. + assertEquals(textInputChannel.updateEditingStateInvocations, 1); + mEditable.replace(0, mEditable.length(), "yet another updated text from the framework"); // Calls updateEditingState. inputConnectionAdaptor.beginBatchEdit(); inputConnectionAdaptor.endBatchEdit(); - // Does not send update because it is the same as the state we just received. - assertEquals(textInputChannel.updateEditingStateInvocations, 1); + // Does send update because it is the same as the state we just received. + assertEquals(textInputChannel.text, "yet another updated text from the framework"); + assertEquals(textInputChannel.updateEditingStateInvocations, 2); } @Test 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 b038eb56000f3..57bedb80cc686 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -120,6 +120,46 @@ public void textInputPlugin_RequestsReattachOnCreation() throws JSONException { verifyMethodCall(bufferCaptor.getValue(), "TextInputClient.requestExistingInputState", null); } + @Test + public void setTextInputEditingState_doesNotInvokeUpdateEditingState() { + // Initialize a general TextInputPlugin. + InputMethodSubtype inputMethodSubtype = mock(InputMethodSubtype.class); + TestImm testImm = + Shadow.extract( + RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE)); + testImm.setCurrentInputMethodSubtype(inputMethodSubtype); + View testView = new View(RuntimeEnvironment.application); + TextInputChannel textInputChannel = spy(new TextInputChannel(mock(DartExecutor.class))); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + textInputPlugin.setTextInputClient( + 0, + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null)); + + textInputPlugin.setTextInputEditingState( + testView, new TextInputChannel.TextEditState("initial input from framework", 0, 0)); + assertTrue(textInputPlugin.getEditable().toString().equals("initial input from framework")); + + verify(textInputChannel, times(0)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + + textInputPlugin.setTextInputEditingState( + testView, new TextInputChannel.TextEditState("more update from the framwork", 1, 1)); + + assertTrue(textInputPlugin.getEditable().toString().equals("more update from the framwork")); + verify(textInputChannel, times(0)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + } + @Test public void setTextInputEditingState_doesNotRestartWhenTextIsIdentical() { // Initialize a general TextInputPlugin. From 163685dbf4ae3a05af274574b24c12ce41c93fef Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 12 Oct 2020 14:52:49 -0700 Subject: [PATCH 04/18] fix tests --- .../io/flutter/plugin/editing/InputConnectionAdaptorTest.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index 0f135e45d2345..b73f218291149 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -950,14 +950,12 @@ public void inputConnectionAdaptor_skipCallsAfterEditingValueUpdateFromFramework DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); int inputTargetId = 0; TestTextInputChannel textInputChannel = new TestTextInputChannel(dartExecutor); - AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); Editable mEditable = Editable.Factory.getInstance().newEditable(""); EditorInfo outAttrs = new EditorInfo(); outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; InputConnectionAdaptor inputConnectionAdaptor = - new InputConnectionAdaptor( - testView, inputTargetId, textInputChannel, mockKeyProcessor, mEditable, outAttrs); + new InputConnectionAdaptor(testView, inputTargetId, textInputChannel, mEditable, outAttrs); inputConnectionAdaptor.setComposingText("initial text", 1); assertEquals(textInputChannel.text, "initial text"); From 810d53a427c636490993f9847e30c9b962e91d08 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 21 Oct 2020 12:56:19 -0700 Subject: [PATCH 05/18] update composing region from framework --- .../systemchannels/TextInputChannel.java | 10 +++++++-- .../editing/InputConnectionAdaptor.java | 13 ++++++++---- .../plugin/editing/TextInputPlugin.java | 21 ++++++++++++++++++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index b05921c84bb1b..a47e2032f8ee5 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -675,17 +675,23 @@ public static TextEditState fromJson(@NonNull JSONObject textEditState) throws J return new TextEditState( textEditState.getString("text"), textEditState.getInt("selectionBase"), - textEditState.getInt("selectionExtent")); + textEditState.getInt("selectionExtent"), + textEditState.getInt("composingBase"), + textEditState.getInt("composingExtent")); } @NonNull public final String text; public final int selectionStart; public final int selectionEnd; + public final int composingStart; + public final int composingEnd; - public TextEditState(@NonNull String text, int selectionStart, int selectionEnd) { + public TextEditState(@NonNull String text, int selectionStart, int selectionEnd, int composingStart, int composingEnd) { this.text = text; this.selectionStart = selectionStart; this.selectionEnd = selectionEnd; + this.composingStart = composingStart; + this.composingEnd = composingEnd; } } } diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index aaddcc35c1441..85b434ca9e1ac 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -54,6 +54,14 @@ private class TextEditingValue { public int composingEnd; public String text; + public TextEditingValue(TextInputChannel.TextEditState state) { + text = state.text; + selectionStart = state.selectionStart; + selectionEnd = state.selectionEnd; + composingStart = state.composingStart; + composingEnd = state.composingEnd; + } + public TextEditingValue(Editable editable) { selectionStart = Selection.getSelectionStart(editable); selectionEnd = Selection.getSelectionEnd(editable); @@ -139,10 +147,7 @@ private void updateEditingState() { TextEditingValue currentValue = new TextEditingValue(mEditable); - // Return if this data has already been sent and no meaningful changes have - // occurred to mark this as dirty. This prevents duplicate remote updates of - // the same data, which can break formatters that change the length of the - // contents. + // Return if no meaningful changes have occurred. if (currentValue.equals(mLastKnownTextEditingValue)) { return; } diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index f88a88f644093..d398a1f168b4f 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -44,6 +44,7 @@ public class TextInputPlugin { @Nullable private Editable mEditable; private boolean mRestartInputPending; @Nullable private InputConnection lastInputConnection; + @Nullable private BaseInputConnection mFakeBaseInputConnection; @NonNull private PlatformViewsController platformViewsController; @Nullable private Rect lastClientRect; private final boolean restartAlwaysRequired; @@ -167,6 +168,19 @@ Editable getEditable() { return mEditable; } + BaseInputConnection getFakeBaseInputConnection() { + if (mFakeBaseInputConnection != null) { + return mFakeBaseInputConnection; + } + mFakeBaseInputConnection = new BaseInputConnection(mView, false) { + @Override + public Editable getEditable() { + return mEditable; + } + }; + return mFakeBaseInputConnection; + } + @VisibleForTesting ImeSyncDeferringInsetsCallback getImeSyncCallback() { return imeSyncCallback; @@ -445,6 +459,11 @@ void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { // Always apply state to selection which handles updating the selection if needed. applyStateToSelection(state); + if (state.composingEnd < 0) + BaseInputConnection.removeComposingSpans(mEditable); + else + getFakeBaseInputConnection().setComposingRegion(state.composingStart, state.composingEnd); + // Use updateSelection to update imm on selection if it is not necessary to restart. if (!restartAlwaysRequired && !mRestartInputPending) { mImm.updateSelection( @@ -605,7 +624,7 @@ public void autofill(SparseArray values) { final TextInputChannel.Configuration.Autofill autofill = config.autofill; final String value = values.valueAt(i).getTextValue().toString(); final TextInputChannel.TextEditState newState = - new TextInputChannel.TextEditState(value, value.length(), value.length()); + new TextInputChannel.TextEditState(value, value.length(), value.length(), -1, -1); // The value of the currently focused text field needs to be updated. if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) { From 7d9865c2006e33cd74f8aa6ba3a4a6ef556e384d Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 26 Oct 2020 17:08:17 -0700 Subject: [PATCH 06/18] implement updateCursorAnchorInfo and updateExtractedText --- .../systemchannels/TextInputChannel.java | 7 +- .../editing/InputConnectionAdaptor.java | 170 ++++++++++++++---- .../plugin/editing/TextInputPlugin.java | 41 ++--- .../plugin/editing/TextInputPluginTest.java | 46 ++--- 4 files changed, 188 insertions(+), 76 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index a47e2032f8ee5..a0780c44ad2f6 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -686,7 +686,12 @@ public static TextEditState fromJson(@NonNull JSONObject textEditState) throws J public final int composingStart; public final int composingEnd; - public TextEditState(@NonNull String text, int selectionStart, int selectionEnd, int composingStart, int composingEnd) { + public TextEditState( + @NonNull String text, + int selectionStart, + int selectionEnd, + int composingStart, + int composingEnd) { this.text = text; this.selectionStart = selectionStart; this.selectionEnd = selectionEnd; diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 85b434ca9e1ac..8f14823fa5698 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -31,11 +31,17 @@ import io.flutter.embedding.engine.systemchannels.TextInputChannel; class InputConnectionAdaptor extends BaseInputConnection { + private static final String TAG = "flutter"; + private final View mFlutterView; private final int mClient; private final TextInputChannel textInputChannel; private final Editable mEditable; private final EditorInfo mEditorInfo; + private ExtractedTextRequest mExtractRequest; + private boolean mMonitorCursorUpdate = false; + private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder; + private ExtractedText mExtractedText = new ExtractedText(); private int mBatchCount; private InputMethodManager mImm; private final Layout mLayout; @@ -43,6 +49,7 @@ class InputConnectionAdaptor extends BaseInputConnection { // Used to determine if Samsung-specific hacks should be applied. private final boolean isSamsung; + private TextEditingValue mLastUpdatedImmEditingValue; private TextEditingValue mLastKnownTextEditingValue; // Data class used to get and store the last-sent values via updateEditingState to // the framework. These are then compared against to prevent redundant messages @@ -114,6 +121,9 @@ public InputConnectionAdaptor( mEditable = editable; mEditorInfo = editorInfo; mBatchCount = 0; + // Initialize the "last seen" text editing values to a non-null value. + mLastUpdatedImmEditingValue = new TextEditingValue(mEditable); + mLastKnownTextEditingValue = mLastUpdatedImmEditingValue; this.flutterTextUtils = new FlutterTextUtils(flutterJNI); // We create a dummy Layout with max width so that the selection // shifting acts as if all text were in one line. @@ -143,7 +153,9 @@ public InputConnectionAdaptor( // Send the current state of the editable to Flutter. private void updateEditingState() { // If the IME is in the middle of a batch edit, then wait until it completes. - if (mBatchCount > 0) return; + if (mBatchCount > 0) { + return; + } TextEditingValue currentValue = new TextEditingValue(mEditable); @@ -152,28 +164,88 @@ private void updateEditingState() { return; } - mImm.updateSelection( - mFlutterView, + Log.v(TAG, "send EditingState to flutter: " + currentValue.toString()); + textInputChannel.updateEditingState( + mClient, + currentValue.text, currentValue.selectionStart, currentValue.selectionEnd, currentValue.composingStart, currentValue.composingEnd); - textInputChannel.updateEditingState( - mClient, - currentValue.text, + mLastKnownTextEditingValue = currentValue; + } + + private ExtractedText getExtractedText(TextEditingValue editingValue) { + mExtractedText.startOffset = 0; + mExtractedText.partialStartOffset = -1; + mExtractedText.partialEndOffset = -1; + mExtractedText.selectionStart = editingValue.selectionStart; + mExtractedText.selectionEnd = editingValue.selectionEnd; + mExtractedText.text = editingValue.text; + return mExtractedText; + } + + private CursorAnchorInfo getCursorAnchorInfo(TextEditingValue editingValue) { + if (mCursorAnchorInfoBuilder == null) { + mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); + } else { + mCursorAnchorInfoBuilder.reset(); + } + + mCursorAnchorInfoBuilder.setSelectionRange( + editingValue.selectionStart, editingValue.selectionEnd); + final int composingStart = editingValue.composingStart; + final int composingEnd = editingValue.composingEnd; + if (composingStart >= 0 && composingEnd > composingStart) { + mCursorAnchorInfoBuilder.setComposingText( + composingStart, editingValue.text.subSequence(composingStart, composingEnd)); + } else { + mCursorAnchorInfoBuilder.setComposingText(-1, ""); + } + return mCursorAnchorInfoBuilder.build(); + } + + private void updateIMMIfNeeded() { + if (mBatchCount > 0) { + return; + } + + TextEditingValue currentValue = new TextEditingValue(mEditable); + + // Always send selection update. InputMethodManager#updateSelection skips sending the message + // if none of the parameters have changed since the last time we called it. + mImm.updateSelection( + mFlutterView, currentValue.selectionStart, currentValue.selectionEnd, currentValue.composingStart, currentValue.composingEnd); - mLastKnownTextEditingValue = currentValue; + if (currentValue == mLastUpdatedImmEditingValue) { + return; + } + + if (mExtractRequest != null) { + mImm.updateExtractedText(mFlutterView, mExtractRequest.token, getExtractedText(currentValue)); + } + + if (mMonitorCursorUpdate) { + final CursorAnchorInfo info = getCursorAnchorInfo(currentValue); + mImm.updateCursorAnchorInfo(mFlutterView, info); + Log.v(TAG, "update CursorAnchorInfo: " + info.toString()); + } + + mLastUpdatedImmEditingValue = currentValue; } - // Called when the current text editing state held by the text input plugin is overwritten by a - // newly received value from the framework. + // Called when the current text editing state held by the text input plugin (in mEditable) is + // overwritten by a newly received value from the framework. public void didUpdateEditingValue() { mLastKnownTextEditingValue = new TextEditingValue(mEditable); + // Try to update the input method immediately after the internal state change. Or defer it to + // endBatchEdit if we're in a nested edit. + updateIMMIfNeeded(); } @Override @@ -191,13 +263,17 @@ public boolean beginBatchEdit() { public boolean endBatchEdit() { boolean result = super.endBatchEdit(); mBatchCount--; + // These 2 methods do nothing if mBatchCount > 0. updateEditingState(); + updateIMMIfNeeded(); return result; } @Override public boolean commitText(CharSequence text, int newCursorPosition) { + beginBatchEdit(); boolean result = super.commitText(text, newCursorPosition); + endBatchEdit(); return result; } @@ -205,64 +281,84 @@ public boolean commitText(CharSequence text, int newCursorPosition) { public boolean deleteSurroundingText(int beforeLength, int afterLength) { if (Selection.getSelectionStart(mEditable) == -1) return true; + beginBatchEdit(); boolean result = super.deleteSurroundingText(beforeLength, afterLength); + endBatchEdit(); return result; } @Override public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { + beginBatchEdit(); boolean result = super.deleteSurroundingTextInCodePoints(beforeLength, afterLength); + endBatchEdit(); return result; } @Override public boolean setComposingRegion(int start, int end) { + beginBatchEdit(); + Log.i("flutter", "engine: set CR: " + String.valueOf(start) + " - " + String.valueOf(end)); boolean result = super.setComposingRegion(start, end); + endBatchEdit(); return result; } @Override public boolean setComposingText(CharSequence text, int newCursorPosition) { boolean result; + beginBatchEdit(); + Log.i("flutter", "engine: set CT: " + text + ", " + String.valueOf(newCursorPosition)); if (text.length() == 0) { result = super.commitText(text, newCursorPosition); } else { result = super.setComposingText(text, newCursorPosition); } + endBatchEdit(); return result; } @Override public boolean finishComposingText() { + Log.i("flutter", "engine: finish composing"); + beginBatchEdit(); boolean result = super.finishComposingText(); - - // 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); - } - } - + endBatchEdit(); return result; } // TODO(garyq): Implement a more feature complete version of getExtractedText @Override public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { - ExtractedText extractedText = new ExtractedText(); - extractedText.selectionStart = Selection.getSelectionStart(mEditable); - extractedText.selectionEnd = Selection.getSelectionEnd(mEditable); - extractedText.text = mEditable.toString(); + // Input methods may use this method to get the current content of the + final boolean textMonitor = (flags & GET_EXTRACTED_TEXT_MONITOR) != 0; + if (textMonitor == (mExtractRequest == null)) { + Log.d(TAG, "The input method toggled text monitoring " + (textMonitor ? "on" : "off")); + } + if (textMonitor) { + // Enables text monitoring. See updateIMMIfNeeded. + mExtractRequest = request; + } else { + mExtractRequest = null; + } + ExtractedText extractedText = getExtractedText(new TextEditingValue(mEditable)); return extractedText; } + @Override + public boolean requestCursorUpdates(int cursorUpdateMode) { + // + + if ((cursorUpdateMode & CURSOR_UPDATE_IMMEDIATE) != 0) { + mImm.updateCursorAnchorInfo( + mFlutterView, getCursorAnchorInfo(new TextEditingValue(mEditable))); + } + + // Enables cursor monitoring. + mMonitorCursorUpdate = (cursorUpdateMode & CURSOR_UPDATE_MONITOR) != 0; + return true; + } + @Override public boolean clearMetaKeyStates(int states) { boolean result = super.clearMetaKeyStates(states); @@ -292,8 +388,9 @@ private boolean isSamsung() { @Override public boolean setSelection(int start, int end) { + beginBatchEdit(); boolean result = super.setSelection(start, end); - updateEditingState(); + endBatchEdit(); return result; } @@ -315,6 +412,13 @@ private static int clampIndexToEditable(int index, Editable editable) { @Override public boolean sendKeyEvent(KeyEvent event) { + beginBatchEdit(); + final boolean result = doSendKeyEvent(event); + endBatchEdit(); + return result; + } + + private boolean doSendKeyEvent(KeyEvent event) { if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { int selStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable); @@ -327,7 +431,6 @@ public boolean sendKeyEvent(KeyEvent event) { // Delete the selection. Selection.setSelection(mEditable, selStart); mEditable.delete(selStart, selEnd); - updateEditingState(); return true; } return false; @@ -418,6 +521,13 @@ public boolean sendKeyEvent(KeyEvent event) { @Override public boolean performContextMenuAction(int id) { + beginBatchEdit(); + final boolean result = doPerformContextMenuAction(id); + endBatchEdit(); + return result; + } + + private boolean doPerformContextMenuAction(int id) { if (id == android.R.id.selectAll) { setSelection(0, mEditable.length()); return true; diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index d398a1f168b4f..43af4e22b83b2 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -172,12 +172,13 @@ BaseInputConnection getFakeBaseInputConnection() { if (mFakeBaseInputConnection != null) { return mFakeBaseInputConnection; } - mFakeBaseInputConnection = new BaseInputConnection(mView, false) { - @Override - public Editable getEditable() { - return mEditable; - } - }; + mFakeBaseInputConnection = + new BaseInputConnection(mView, true) { + @Override + public Editable getEditable() { + return mEditable; + } + }; return mFakeBaseInputConnection; } @@ -459,31 +460,25 @@ void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { // Always apply state to selection which handles updating the selection if needed. applyStateToSelection(state); - if (state.composingEnd < 0) + if (state.composingEnd < 0) { BaseInputConnection.removeComposingSpans(mEditable); - else - getFakeBaseInputConnection().setComposingRegion(state.composingStart, state.composingEnd); - - // Use updateSelection to update imm on selection if it is not necessary to restart. - if (!restartAlwaysRequired && !mRestartInputPending) { - mImm.updateSelection( - mView, - Math.max(Selection.getSelectionStart(mEditable), 0), - Math.max(Selection.getSelectionEnd(mEditable), 0), - BaseInputConnection.getComposingSpanStart(mEditable), - BaseInputConnection.getComposingSpanEnd(mEditable)); - // Restart if there is a pending restart or the device requires a force restart - // (see isRestartAlwaysRequired). Restarting will also update the selection. } else { - mImm.restartInput(view); - mRestartInputPending = false; + getFakeBaseInputConnection().setComposingRegion(state.composingStart, state.composingEnd); } - // Notify the connection adaptor that the last known remote editing state has been updated. + // Notify the connection adaptor that the mEditable has been updated. The InputConnection is + // responsible for further notifying the input method. InputConnection connection = getLastInputConnection(); if (connection != null && connection instanceof InputConnectionAdaptor) { ((InputConnectionAdaptor) connection).didUpdateEditingValue(); } + + // Restart if there is a pending restart or the device requires a force restart + // (see isRestartAlwaysRequired). Restarting will also update the selection. + if (restartAlwaysRequired || mRestartInputPending) { + mImm.restartInput(view); + mRestartInputPending = false; + } } private interface MinMax { 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 ba55c2325836e..d4e70e8172c39 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -146,14 +146,15 @@ public void setTextInputEditingState_doesNotInvokeUpdateEditingState() { null)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("initial input from framework", 0, 0)); + testView, new TextInputChannel.TextEditState("initial input from framework", 0, 0, -1, -1)); assertTrue(textInputPlugin.getEditable().toString().equals("initial input from framework")); verify(textInputChannel, times(0)) .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("more update from the framwork", 1, 1)); + testView, + new TextInputChannel.TextEditState("more update from the framwork", 1, 1, -1, -1)); assertTrue(textInputPlugin.getEditable().toString().equals("more update from the framwork")); verify(textInputChannel, times(0)) @@ -186,12 +187,12 @@ public void setTextInputEditingState_doesNotRestartWhenTextIsIdentical() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Verify that we haven't restarted the input. assertEquals(1, testImm.getRestartCount(testView)); @@ -225,13 +226,13 @@ public void setTextInputEditingState_alwaysSetEditableWhenDifferent() { // changed text, we should // always set the Editable contents. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("hello", 0, 0)); + testView, new TextInputChannel.TextEditState("hello", 0, 0, -1, -1)); assertEquals(1, testImm.getRestartCount(testView)); assertTrue(textInputPlugin.getEditable().toString().equals("hello")); // No pending restart, set Editable contents anyways. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("Shibuyawoo", 0, 0)); + testView, new TextInputChannel.TextEditState("Shibuyawoo", 0, 0, -1, -1)); assertEquals(1, testImm.getRestartCount(testView)); assertTrue(textInputPlugin.getEditable().toString().equals("Shibuyawoo")); } @@ -272,12 +273,12 @@ public void setTextInputEditingState_alwaysRestartsOnAffectedDevices2() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Verify that we've restarted the input. assertEquals(2, testImm.getRestartCount(testView)); @@ -315,12 +316,12 @@ public void setTextInputEditingState_doesNotRestartOnUnaffectedDevices() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Verify that we've restarted the input. assertEquals(1, testImm.getRestartCount(testView)); @@ -351,7 +352,7 @@ public void setTextInputEditingState_nullInputMethodSubtype() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); assertEquals(1, testImm.getRestartCount(testView)); } @@ -393,7 +394,7 @@ public void inputConnection_createsActionFromEnter() throws JSONException { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); ArgumentCaptor channelCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor bufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class); @@ -465,17 +466,16 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("text", 0, 0, -1, -1)); InputConnection connection = textInputPlugin.createInputConnection(testView, new EditorInfo()); + connection.requestCursorUpdates( + InputConnection.CURSOR_UPDATE_MONITOR | InputConnection.CURSOR_UPDATE_IMMEDIATE); connection.finishComposingText(); - if (Build.VERSION.SDK_INT >= 21) { - CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder(); - builder.setComposingText(-1, ""); - CursorAnchorInfo anchorInfo = builder.build(); - assertEquals(testImm.getLastCursorAnchorInfo(), anchorInfo); - } + System.out.println(testImm.getLastCursorAnchorInfo()); + assertEquals(-1, testImm.getLastCursorAnchorInfo().getComposingTextStart()); + assertEquals(0, testImm.getLastCursorAnchorInfo().getComposingText().length()); } @Test @@ -488,10 +488,12 @@ public void autofill_onProvideVirtualViewStructure() { new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); final TextInputChannel.Configuration.Autofill autofill1 = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration.Autofill autofill2 = new TextInputChannel.Configuration.Autofill( - "2", new String[] {"HINT2", "EXTRA"}, new TextInputChannel.TextEditState("", 0, 0)); + "2", + new String[] {"HINT2", "EXTRA"}, + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration config1 = new TextInputChannel.Configuration( @@ -561,7 +563,7 @@ public void autofill_onProvideVirtualViewStructure_single() { new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); final TextInputChannel.Configuration.Autofill autofill = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Autofill should still work without AutofillGroup. textInputPlugin.setTextInputClient( From a33db84bef07b399f9c09adeb831a7aeba3762a3 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 28 Oct 2020 22:35:32 -0700 Subject: [PATCH 07/18] wip --- shell/platform/android/BUILD.gn | 1 + .../systemchannels/TextInputChannel.java | 8 + .../editing/InputConnectionAdaptor.java | 249 ++++--------- .../plugin/editing/TextInputPlugin.java | 332 ++++++++++-------- .../editing/InputConnectionAdaptorTest.java | 158 ++------- .../plugin/editing/TextInputPluginTest.java | 97 ++++- 6 files changed, 386 insertions(+), 459 deletions(-) diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 91d2a368b1057..9f080f145b8f5 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -216,6 +216,7 @@ android_java_sources = [ "io/flutter/plugin/editing/FlutterTextUtils.java", "io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java", "io/flutter/plugin/editing/InputConnectionAdaptor.java", + "io/flutter/plugin/editing/ListenableEditingState.java", "io/flutter/plugin/editing/TextInputPlugin.java", "io/flutter/plugin/localization/LocalizationPlugin.java", "io/flutter/plugin/mouse/MouseCursorPlugin.java", diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index a0780c44ad2f6..14871a54c1608 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -692,6 +692,14 @@ public TextEditState( int selectionEnd, int composingStart, int composingEnd) { + + assert (selectionStart == -1 && selectionEnd == -1) + || (selectionStart >= 0 && selectionStart <= selectionEnd); + assert (composingStart == -1 && composingEnd == -1) + || (composingStart >= 0 && composingStart < composingEnd); + assert composingEnd <= text.length(); + assert selectionEnd <= text.length(); + this.text = text; this.selectionStart = selectionStart; this.selectionEnd = selectionEnd; diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 8f14823fa5698..cd3d8996f1dad 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -30,88 +30,29 @@ import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.systemchannels.TextInputChannel; -class InputConnectionAdaptor extends BaseInputConnection { +class InputConnectionAdaptor extends BaseInputConnection + implements ListenableEditingState.EditingStateWatcher { private static final String TAG = "flutter"; private final View mFlutterView; private final int mClient; private final TextInputChannel textInputChannel; - private final Editable mEditable; + private final ListenableEditingState mEditable; private final EditorInfo mEditorInfo; private ExtractedTextRequest mExtractRequest; private boolean mMonitorCursorUpdate = false; private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder; private ExtractedText mExtractedText = new ExtractedText(); - private int mBatchCount; private InputMethodManager mImm; private final Layout mLayout; private FlutterTextUtils flutterTextUtils; - // Used to determine if Samsung-specific hacks should be applied. - private final boolean isSamsung; - - private TextEditingValue mLastUpdatedImmEditingValue; - private TextEditingValue mLastKnownTextEditingValue; - // Data class used to get and store the last-sent values via updateEditingState to - // the framework. These are then compared against to prevent redundant messages - // with the same data before any valid operations were made to the contents. - private class TextEditingValue { - public int selectionStart; - public int selectionEnd; - public int composingStart; - public int composingEnd; - public String text; - - public TextEditingValue(TextInputChannel.TextEditState state) { - text = state.text; - selectionStart = state.selectionStart; - selectionEnd = state.selectionEnd; - composingStart = state.composingStart; - composingEnd = state.composingEnd; - } - - public TextEditingValue(Editable editable) { - selectionStart = Selection.getSelectionStart(editable); - selectionEnd = Selection.getSelectionEnd(editable); - composingStart = BaseInputConnection.getComposingSpanStart(editable); - composingEnd = BaseInputConnection.getComposingSpanEnd(editable); - text = editable.toString(); - } - - @Override - public boolean equals(Object o) { - if (o == this) { - return true; - } - if (!(o instanceof TextEditingValue)) { - return false; - } - TextEditingValue value = (TextEditingValue) o; - return selectionStart == value.selectionStart - && selectionEnd == value.selectionEnd - && composingStart == value.composingStart - && composingEnd == value.composingEnd - && text.equals(value.text); - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + selectionStart; - result = prime * result + selectionEnd; - result = prime * result + composingStart; - result = prime * result + composingEnd; - result = prime * result + text.hashCode(); - return result; - } - } @SuppressWarnings("deprecation") public InputConnectionAdaptor( View view, int client, TextInputChannel textInputChannel, - Editable editable, + ListenableEditingState editable, EditorInfo editorInfo, FlutterJNI flutterJNI) { super(view, true); @@ -119,11 +60,8 @@ public InputConnectionAdaptor( mClient = client; this.textInputChannel = textInputChannel; mEditable = editable; + mEditable.addEditingStateListener(this); mEditorInfo = editorInfo; - mBatchCount = 0; - // Initialize the "last seen" text editing values to a non-null value. - mLastUpdatedImmEditingValue = new TextEditingValue(mEditable); - mLastKnownTextEditingValue = mLastUpdatedImmEditingValue; this.flutterTextUtils = new FlutterTextUtils(flutterJNI); // We create a dummy Layout with max width so that the selection // shifting acts as if all text were in one line. @@ -137,56 +75,28 @@ public InputConnectionAdaptor( 0.0f, false); mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - - isSamsung = isSamsung(); } public InputConnectionAdaptor( View view, int client, TextInputChannel textInputChannel, - Editable editable, + ListenableEditingState editable, EditorInfo editorInfo) { this(view, client, textInputChannel, editable, editorInfo, new FlutterJNI()); } - // Send the current state of the editable to Flutter. - private void updateEditingState() { - // If the IME is in the middle of a batch edit, then wait until it completes. - if (mBatchCount > 0) { - return; - } - - TextEditingValue currentValue = new TextEditingValue(mEditable); - - // Return if no meaningful changes have occurred. - if (currentValue.equals(mLastKnownTextEditingValue)) { - return; - } - - Log.v(TAG, "send EditingState to flutter: " + currentValue.toString()); - textInputChannel.updateEditingState( - mClient, - currentValue.text, - currentValue.selectionStart, - currentValue.selectionEnd, - currentValue.composingStart, - currentValue.composingEnd); - - mLastKnownTextEditingValue = currentValue; - } - - private ExtractedText getExtractedText(TextEditingValue editingValue) { + private ExtractedText getExtractedText() { mExtractedText.startOffset = 0; mExtractedText.partialStartOffset = -1; mExtractedText.partialEndOffset = -1; - mExtractedText.selectionStart = editingValue.selectionStart; - mExtractedText.selectionEnd = editingValue.selectionEnd; - mExtractedText.text = editingValue.text; + mExtractedText.selectionStart = mEditable.getSelecionStart(); + mExtractedText.selectionEnd = mEditable.getSelecionEnd(); + mExtractedText.text = mEditable.toString(); return mExtractedText; } - private CursorAnchorInfo getCursorAnchorInfo(TextEditingValue editingValue) { + private CursorAnchorInfo getCursorAnchorInfo() { if (mCursorAnchorInfoBuilder == null) { mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); } else { @@ -194,60 +104,18 @@ private CursorAnchorInfo getCursorAnchorInfo(TextEditingValue editingValue) { } mCursorAnchorInfoBuilder.setSelectionRange( - editingValue.selectionStart, editingValue.selectionEnd); - final int composingStart = editingValue.composingStart; - final int composingEnd = editingValue.composingEnd; + mEditable.getSelecionStart(), mEditable.getSelecionEnd()); + final int composingStart = mEditable.getComposingStart(); + final int composingEnd = mEditable.getComposingEnd(); if (composingStart >= 0 && composingEnd > composingStart) { mCursorAnchorInfoBuilder.setComposingText( - composingStart, editingValue.text.subSequence(composingStart, composingEnd)); + composingStart, mEditable.toString().subSequence(composingStart, composingEnd)); } else { mCursorAnchorInfoBuilder.setComposingText(-1, ""); } return mCursorAnchorInfoBuilder.build(); } - private void updateIMMIfNeeded() { - if (mBatchCount > 0) { - return; - } - - TextEditingValue currentValue = new TextEditingValue(mEditable); - - // Always send selection update. InputMethodManager#updateSelection skips sending the message - // if none of the parameters have changed since the last time we called it. - mImm.updateSelection( - mFlutterView, - currentValue.selectionStart, - currentValue.selectionEnd, - currentValue.composingStart, - currentValue.composingEnd); - - if (currentValue == mLastUpdatedImmEditingValue) { - return; - } - - if (mExtractRequest != null) { - mImm.updateExtractedText(mFlutterView, mExtractRequest.token, getExtractedText(currentValue)); - } - - if (mMonitorCursorUpdate) { - final CursorAnchorInfo info = getCursorAnchorInfo(currentValue); - mImm.updateCursorAnchorInfo(mFlutterView, info); - Log.v(TAG, "update CursorAnchorInfo: " + info.toString()); - } - - mLastUpdatedImmEditingValue = currentValue; - } - - // Called when the current text editing state held by the text input plugin (in mEditable) is - // overwritten by a newly received value from the framework. - public void didUpdateEditingValue() { - mLastKnownTextEditingValue = new TextEditingValue(mEditable); - // Try to update the input method immediately after the internal state change. Or defer it to - // endBatchEdit if we're in a nested edit. - updateIMMIfNeeded(); - } - @Override public Editable getEditable() { return mEditable; @@ -255,75 +123,62 @@ public Editable getEditable() { @Override public boolean beginBatchEdit() { - mBatchCount++; + mEditable.beginBatchEdit(); return super.beginBatchEdit(); } @Override public boolean endBatchEdit() { boolean result = super.endBatchEdit(); - mBatchCount--; - // These 2 methods do nothing if mBatchCount > 0. - updateEditingState(); - updateIMMIfNeeded(); + mEditable.endBatchEdit(); return result; } @Override public boolean commitText(CharSequence text, int newCursorPosition) { - beginBatchEdit(); - boolean result = super.commitText(text, newCursorPosition); - endBatchEdit(); + final boolean result = super.commitText(text, newCursorPosition); return result; } @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - if (Selection.getSelectionStart(mEditable) == -1) return true; + if (Selection.getSelectionStart(mEditable) == -1) { + return true; + } - beginBatchEdit(); - boolean result = super.deleteSurroundingText(beforeLength, afterLength); - endBatchEdit(); + final boolean result = super.deleteSurroundingText(beforeLength, afterLength); return result; } @Override public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { - beginBatchEdit(); boolean result = super.deleteSurroundingTextInCodePoints(beforeLength, afterLength); - endBatchEdit(); return result; } @Override public boolean setComposingRegion(int start, int end) { - beginBatchEdit(); Log.i("flutter", "engine: set CR: " + String.valueOf(start) + " - " + String.valueOf(end)); - boolean result = super.setComposingRegion(start, end); - endBatchEdit(); + final boolean result = super.setComposingRegion(start, end); return result; } @Override public boolean setComposingText(CharSequence text, int newCursorPosition) { boolean result; - beginBatchEdit(); Log.i("flutter", "engine: set CT: " + text + ", " + String.valueOf(newCursorPosition)); if (text.length() == 0) { result = super.commitText(text, newCursorPosition); } else { result = super.setComposingText(text, newCursorPosition); } - endBatchEdit(); return result; } @Override public boolean finishComposingText() { Log.i("flutter", "engine: finish composing"); - beginBatchEdit(); - boolean result = super.finishComposingText(); - endBatchEdit(); + final boolean result = super.finishComposingText(); return result; } @@ -335,26 +190,19 @@ public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { if (textMonitor == (mExtractRequest == null)) { Log.d(TAG, "The input method toggled text monitoring " + (textMonitor ? "on" : "off")); } - if (textMonitor) { - // Enables text monitoring. See updateIMMIfNeeded. - mExtractRequest = request; - } else { - mExtractRequest = null; - } - ExtractedText extractedText = getExtractedText(new TextEditingValue(mEditable)); - return extractedText; + // Enables text monitoring if the relevant flag is set. See + // InputConnectionAdaptor#didChangeEditingState. + mExtractRequest = textMonitor ? request : null; + return getExtractedText(); } @Override public boolean requestCursorUpdates(int cursorUpdateMode) { - // - if ((cursorUpdateMode & CURSOR_UPDATE_IMMEDIATE) != 0) { - mImm.updateCursorAnchorInfo( - mFlutterView, getCursorAnchorInfo(new TextEditingValue(mEditable))); + mImm.updateCursorAnchorInfo(mFlutterView, getCursorAnchorInfo()); } - // Enables cursor monitoring. + // Enables cursor monitoring. See InputConnectionAdaptor#didChangeEditingState. mMonitorCursorUpdate = (cursorUpdateMode & CURSOR_UPDATE_MONITOR) != 0; return true; } @@ -365,6 +213,12 @@ public boolean clearMetaKeyStates(int states) { return result; } + @Override + public void closeConnection() { + super.closeConnection(); + mEditable.removeEditingStateListener(this); + } + // 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. @@ -412,13 +266,6 @@ private static int clampIndexToEditable(int index, Editable editable) { @Override public boolean sendKeyEvent(KeyEvent event) { - beginBatchEdit(); - final boolean result = doSendKeyEvent(event); - endBatchEdit(); - return result; - } - - private boolean doSendKeyEvent(KeyEvent event) { if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { int selStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable); @@ -616,4 +463,28 @@ public boolean performEditorAction(int actionCode) { } return true; } + + // -------- Start: ListenableEditingState watcher implementation ------- + @Override + public void didChangeEditingState( + boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { + // Always send selection update. InputMethodManager#updateSelection skips sending the message + // if none of the parameters have changed since the last time we called it. + mImm.updateSelection( + mFlutterView, + mEditable.getSelecionStart(), + mEditable.getSelecionEnd(), + mEditable.getComposingStart(), + mEditable.getComposingEnd()); + + if (mExtractRequest != null) { + mImm.updateExtractedText(mFlutterView, mExtractRequest.token, getExtractedText()); + } + if (mMonitorCursorUpdate) { + final CursorAnchorInfo info = getCursorAnchorInfo(); + mImm.updateCursorAnchorInfo(mFlutterView, info); + Log.v(TAG, "update CursorAnchorInfo: " + info.toString()); + } + } + // -------- End: ListenableEditingState watcher implementation ------- } diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 43af4e22b83b2..890eac94b8d19 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -12,7 +12,6 @@ import android.provider.Settings; import android.text.Editable; import android.text.InputType; -import android.text.Selection; import android.util.SparseArray; import android.view.View; import android.view.ViewStructure; @@ -20,7 +19,6 @@ import android.view.autofill.AutofillId; import android.view.autofill.AutofillManager; import android.view.autofill.AutofillValue; -import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; @@ -28,12 +26,16 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import io.flutter.Log; import io.flutter.embedding.engine.systemchannels.TextInputChannel; +import io.flutter.embedding.engine.systemchannels.TextInputChannel.TextEditState; import io.flutter.plugin.platform.PlatformViewsController; import java.util.HashMap; /** Android implementation of the text input plugin. */ -public class TextInputPlugin { +public class TextInputPlugin implements ListenableEditingState.EditingStateWatcher { + private static final String TAG = "flutter"; + @NonNull private final View mView; @NonNull private final InputMethodManager mImm; @NonNull private final AutofillManager afm; @@ -41,15 +43,17 @@ public class TextInputPlugin { @NonNull private InputTarget inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); @Nullable private TextInputChannel.Configuration configuration; @Nullable private SparseArray mAutofillConfigurations; - @Nullable private Editable mEditable; + @Nullable private ListenableEditingState mEditable; private boolean mRestartInputPending; @Nullable private InputConnection lastInputConnection; - @Nullable private BaseInputConnection mFakeBaseInputConnection; @NonNull private PlatformViewsController platformViewsController; @Nullable private Rect lastClientRect; private final boolean restartAlwaysRequired; private ImeSyncDeferringInsetsCallback imeSyncCallback; + // Initialize the "last seen" text editing values to a non-null value. + private TextEditState mLastKnownFrameworkTextEditingState; + // When true following calls to createInputConnection will return the cached lastInputConnection // if the input // target is a platform view. See the comments on lockPlatformViewInputConnection for more @@ -168,20 +172,6 @@ Editable getEditable() { return mEditable; } - BaseInputConnection getFakeBaseInputConnection() { - if (mFakeBaseInputConnection != null) { - return mFakeBaseInputConnection; - } - mFakeBaseInputConnection = - new BaseInputConnection(mView, true) { - @Override - public Editable getEditable() { - return mEditable; - } - }; - return mFakeBaseInputConnection; - } - @VisibleForTesting ImeSyncDeferringInsetsCallback getImeSyncCallback() { return imeSyncCallback; @@ -224,6 +214,9 @@ public void unlockPlatformViewInputConnection() { public void destroy() { platformViewsController.detachTextInputPlugin(); textInputChannel.setTextInputMethodHandler(null); + if (mEditable != null) { + mEditable.removeEditingStateListener(this); + } if (imeSyncCallback != null) { imeSyncCallback.remove(); } @@ -329,8 +322,8 @@ public InputConnection createInputConnection(View view, EditorInfo outAttrs) { InputConnectionAdaptor connection = new InputConnectionAdaptor(view, inputTarget.id, textInputChannel, mEditable, outAttrs); - outAttrs.initialSelStart = Selection.getSelectionStart(mEditable); - outAttrs.initialSelEnd = Selection.getSelectionEnd(mEditable); + outAttrs.initialSelStart = mEditable.getSelecionStart(); + outAttrs.initialSelEnd = mEditable.getSelecionEnd(); lastInputConnection = connection; return lastInputConnection; @@ -401,7 +394,7 @@ private void notifyViewExited() { afm.notifyViewExited(mView, triggerIdentifier.hashCode()); } - private void notifyValueChanged(String newValue) { + public void notifyValueChanged(String newValue) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { return; } @@ -413,14 +406,21 @@ private void notifyValueChanged(String newValue) { @VisibleForTesting void setTextInputClient(int client, TextInputChannel.Configuration configuration) { inputTarget = new InputTarget(InputTarget.Type.FRAMEWORK_CLIENT, client); + + if (mEditable != null) { + mEditable.removeEditingStateListener(this); + } + mEditable = + new ListenableEditingState( + configuration.autofill != null ? configuration.autofill.editState : null); updateAutofillConfigurationIfNeeded(configuration); - mEditable = Editable.Factory.getInstance().newEditable(""); // setTextInputClient will be followed by a call to setTextInputEditingState. // Do a restartInput at that time. mRestartInputPending = true; unlockPlatformViewInputConnection(); lastClientRect = null; + mEditable.addEditingStateListener(this); } private void setPlatformViewTextInputClient(int platformViewId) { @@ -435,43 +435,10 @@ private void setPlatformViewTextInputClient(int platformViewId) { mRestartInputPending = false; } - private void applyStateToSelection(TextInputChannel.TextEditState state) { - int selStart = state.selectionStart; - int selEnd = state.selectionEnd; - if (selStart >= 0 - && selStart <= mEditable.length() - && selEnd >= 0 - && selEnd <= mEditable.length()) { - Selection.setSelection(mEditable, selStart, selEnd); - } else { - Selection.removeSelection(mEditable); - } - } - @VisibleForTesting void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { - // Always replace the contents of mEditable if the text differs - if (!state.text.equals(mEditable.toString())) { - mEditable.replace(0, mEditable.length(), state.text); - } - - // Notify the autofill manager of the value change. - notifyValueChanged(mEditable.toString()); - // Always apply state to selection which handles updating the selection if needed. - applyStateToSelection(state); - - if (state.composingEnd < 0) { - BaseInputConnection.removeComposingSpans(mEditable); - } else { - getFakeBaseInputConnection().setComposingRegion(state.composingStart, state.composingEnd); - } - - // Notify the connection adaptor that the mEditable has been updated. The InputConnection is - // responsible for further notifying the input method. - InputConnection connection = getLastInputConnection(); - if (connection != null && connection instanceof InputConnectionAdaptor) { - ((InputConnectionAdaptor) connection).didUpdateEditingValue(); - } + mLastKnownFrameworkTextEditingState = state; + mEditable.setEditingState(state); // Restart if there is a pending restart or the device requires a force restart // (see isRestartAlwaysRequired). Restarting will also update the selection. @@ -525,6 +492,152 @@ public void inspect(double x, double y) { (int) Math.ceil(minMax[3] * density)); } + // Samsung's Korean keyboard has a bug where it always attempts to combine characters based on + // its internal state, ignoring if and when the cursor is moved programmatically. The same bug + // also causes non-korean keyboards to occasionally duplicate text when tapping in the middle + // of existing text to edit it. + // + // Fully restarting the IMM works around this because it flushes the keyboard's internal state + // and stops it from trying to incorrectly combine characters. However this also has some + // negative performance implications, so we don't want to apply this workaround in every case. + @SuppressLint("NewApi") // New API guard is inline, the linter can't see it. + @SuppressWarnings("deprecation") + private boolean isRestartAlwaysRequired() { + 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( + mView.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"); + } + + private void clearTextInputClient() { + if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { + // Focus changes in the framework tree have no guarantees on the order focus nodes are + // notified. A node + // that lost focus may be notified before or after a node that gained focus. + // When moving the focus from a Flutter text field to an AndroidView, it is possible that the + // Flutter text + // field's focus node will be notified that it lost focus after the AndroidView was notified + // that it gained + // focus. When this happens the text field will send a clearTextInput command which we ignore. + // By doing this we prevent the framework from clearing a platform view input client(the only + // way to do so + // is to set a new framework text client). I don't see an obvious use case for "clearing" a + // platform views + // text input client, and it may be error prone as we don't know how the platform view manages + // the input + // connection and we probably shouldn't interfere. + // If we ever want to allow the framework to clear a platform view text client we should + // probably consider + // changing the focus manager such that focus nodes that lost focus are notified before focus + // nodes that + // gained focus as part of the same focus event. + return; + } + mEditable.removeEditingStateListener(this); + inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); + unlockPlatformViewInputConnection(); + notifyViewExited(); + lastClientRect = null; + } + + private static class InputTarget { + enum Type { + NO_TARGET, + // InputConnection is managed by the TextInputPlugin, and events are forwarded to the Flutter + // framework. + FRAMEWORK_CLIENT, + // InputConnection is managed by an embedded platform view. + PLATFORM_VIEW + } + + public InputTarget(@NonNull Type type, int id) { + this.type = type; + this.id = id; + } + + @NonNull Type type; + // The ID of the input target. + // + // For framework clients this is the framework input connection client ID. + // For platform views this is the platform view's ID. + int id; + } + + // -------- Start: ListenableEditingState watcher implementation ------- + + @Override + public void didChangeEditingState( + boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { + if (textChanged) { + // Notify the autofill manager of the value change. + notifyValueChanged(mEditable.toString()); + } + + final int selectionStart = mEditable.getSelecionStart(); + final int selectionEnd = mEditable.getSelecionEnd(); + final int composingStart = mEditable.getComposingStart(); + final int composingEnd = mEditable.getComposingEnd(); + // Framework needs to sent value first. + final boolean skipFrameworkUpdate = + mLastKnownFrameworkTextEditingState == null + || (mEditable.toString().equals(mLastKnownFrameworkTextEditingState.text) + && selectionStart == mLastKnownFrameworkTextEditingState.selectionStart + && selectionEnd == mLastKnownFrameworkTextEditingState.selectionEnd + && composingStart == mLastKnownFrameworkTextEditingState.composingStart + && composingEnd == mLastKnownFrameworkTextEditingState.composingEnd); + // Skip if we're currently setting + if (!skipFrameworkUpdate) { + Log.v(TAG, "send EditingState to flutter: " + mEditable.toString()); + Log.i(TAG, mEditable.toString() + " == " + mLastKnownFrameworkTextEditingState.text); + Log.i( + TAG, + String.valueOf(selectionStart) + + " == " + + String.valueOf(mLastKnownFrameworkTextEditingState.selectionStart)); + Log.i( + TAG, + String.valueOf(selectionEnd) + + " == " + + String.valueOf(mLastKnownFrameworkTextEditingState.selectionEnd)); + Log.i( + TAG, + String.valueOf(composingStart) + + " == " + + String.valueOf(mLastKnownFrameworkTextEditingState.composingStart)); + Log.i( + TAG, + String.valueOf(composingEnd) + + " == " + + String.valueOf(mLastKnownFrameworkTextEditingState.composingEnd)); + textInputChannel.updateEditingState( + inputTarget.id, + mEditable.toString(), + selectionStart, + selectionEnd, + composingStart, + composingEnd); + mLastKnownFrameworkTextEditingState = + new TextEditState( + mEditable.toString(), selectionStart, selectionEnd, composingStart, composingEnd); + } + } + + // -------- End: ListenableEditingState watcher implementation ------- + + // -------- Start: Autofill ------- + private boolean needsAutofill() { + return mAutofillConfigurations != null; + } + private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration configuration) { notifyViewExited(); this.configuration = configuration; @@ -544,19 +657,17 @@ private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration } else { for (TextInputChannel.Configuration config : configurations) { TextInputChannel.Configuration.Autofill autofill = config.autofill; - if (autofill == null) { - continue; + if (autofill != null) { + mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config); + afm.notifyValueChanged( + mView, + autofill.uniqueIdentifier.hashCode(), + AutofillValue.forText(autofill.editState.text)); } - - mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config); } } } - private boolean needsAutofill() { - return mAutofillConfigurations != null; - } - public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !needsAutofill()) { return; @@ -575,13 +686,13 @@ public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags structure.addChildCount(1); final ViewStructure child = structure.newChild(i); child.setAutofillId(parentId, autofillId); - child.setAutofillValue(AutofillValue.forText(autofill.editState.text)); child.setAutofillHints(autofill.hints); child.setAutofillType(View.AUTOFILL_TYPE_TEXT); child.setVisibility(View.VISIBLE); - // Some autofill services expect child structures to be visible. - // Reports the real size of the child if it's the current client. + // For some autofill services, only visible input fields are eligible for autofill. + // Reports the real size of the child if it's the current client, or 1x1 if we don't + // know the real dimensions of the child. if (triggerIdentifier.hashCode() == autofillId && lastClientRect != null) { child.setDimens( lastClientRect.left, @@ -590,9 +701,10 @@ public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags 0, lastClientRect.width(), lastClientRect.height()); + child.setAutofillValue(AutofillValue.forText(mEditable.toString())); } else { - // Reports a fake dimension that's still visible. child.setDimens(0, 0, 0, 0, 1, 1); + child.setAutofillValue(AutofillValue.forText(autofill.editState.text)); } } } @@ -630,83 +742,5 @@ public void autofill(SparseArray values) { textInputChannel.updateEditingStateWithTag(inputTarget.id, editingValues); } - - // Samsung's Korean keyboard has a bug where it always attempts to combine characters based on - // its internal state, ignoring if and when the cursor is moved programmatically. The same bug - // also causes non-korean keyboards to occasionally duplicate text when tapping in the middle - // of existing text to edit it. - // - // Fully restarting the IMM works around this because it flushes the keyboard's internal state - // and stops it from trying to incorrectly combine characters. However this also has some - // negative performance implications, so we don't want to apply this workaround in every case. - @SuppressLint("NewApi") // New API guard is inline, the linter can't see it. - @SuppressWarnings("deprecation") - private boolean isRestartAlwaysRequired() { - 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( - mView.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"); - } - - private void clearTextInputClient() { - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { - // Focus changes in the framework tree have no guarantees on the order focus nodes are - // notified. A node - // that lost focus may be notified before or after a node that gained focus. - // When moving the focus from a Flutter text field to an AndroidView, it is possible that the - // Flutter text - // field's focus node will be notified that it lost focus after the AndroidView was notified - // that it gained - // focus. When this happens the text field will send a clearTextInput command which we ignore. - // By doing this we prevent the framework from clearing a platform view input client(the only - // way to do so - // is to set a new framework text client). I don't see an obvious use case for "clearing" a - // platform views - // text input client, and it may be error prone as we don't know how the platform view manages - // the input - // connection and we probably shouldn't interfere. - // If we ever want to allow the framework to clear a platform view text client we should - // probably consider - // changing the focus manager such that focus nodes that lost focus are notified before focus - // nodes that - // gained focus as part of the same focus event. - return; - } - inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); - unlockPlatformViewInputConnection(); - notifyViewExited(); - lastClientRect = null; - } - - private static class InputTarget { - enum Type { - NO_TARGET, - // InputConnection is managed by the TextInputPlugin, and events are forwarded to the Flutter - // framework. - FRAMEWORK_CLIENT, - // InputConnection is managed by an embedded platform view. - PLATFORM_VIEW - } - - public InputTarget(@NonNull Type type, int id) { - this.type = type; - this.id = id; - } - - @NonNull Type type; - // The ID of the input target. - // - // For framework clients this is the framework input connection client ID. - // For platform views this is the platform view's ID. - int id; - } + // -------- End: Autofill ------- } diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index b73f218291149..0a9d1ece537a8 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -16,11 +16,9 @@ import android.content.ClipboardManager; import android.content.res.AssetManager; import android.os.Bundle; -import android.text.Editable; import android.text.Emoji; import android.text.InputType; import android.text.Selection; -import android.text.SpannableStringBuilder; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.EditorInfo; @@ -68,8 +66,8 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); int inputTargetId = 0; TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable mEditable = Editable.Factory.getInstance().newEditable(""); - Editable spyEditable = spy(mEditable); + ListenableEditingState mEditable = new ListenableEditingState(null); + ListenableEditingState spyEditable = spy(mEditable); EditorInfo outAttrs = new EditorInfo(); outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; @@ -86,7 +84,7 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { @Test public void testPerformContextMenuAction_selectAll() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); boolean didConsume = adaptor.performContextMenuAction(android.R.id.selectAll); @@ -102,7 +100,7 @@ public void testPerformContextMenuAction_cut() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); int selStart = 6; int selEnd = 11; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); CharSequence textToBeCut = editable.subSequence(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); @@ -120,7 +118,7 @@ public void testPerformContextMenuAction_copy() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); int selStart = 6; int selEnd = 11; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); assertFalse(clipboardManager.hasText()); @@ -140,7 +138,7 @@ public void testPerformContextMenuAction_paste() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); String textToBePasted = "deadbeef"; clipboardManager.setText(textToBePasted); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); boolean didConsume = adaptor.performContextMenuAction(android.R.id.paste); @@ -156,7 +154,7 @@ public void testPerformPrivateCommand_dataIsNull() throws JSONException { FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -183,7 +181,7 @@ public void testPerformPrivateCommand_dataIsByteArray() throws JSONException { FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -216,7 +214,7 @@ public void testPerformPrivateCommand_dataIsByte() throws JSONException { FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -247,7 +245,7 @@ public void testPerformPrivateCommand_dataIsCharArray() throws JSONException { FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -281,7 +279,7 @@ public void testPerformPrivateCommand_dataIsChar() throws JSONException { FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -312,7 +310,7 @@ public void testPerformPrivateCommand_dataIsCharSequenceArray() throws JSONExcep FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -347,7 +345,7 @@ public void testPerformPrivateCommand_dataIsCharSequence() throws JSONException FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -380,7 +378,7 @@ public void testPerformPrivateCommand_dataIsFloat() throws JSONException { FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -411,7 +409,7 @@ public void testPerformPrivateCommand_dataIsFloatArray() throws JSONException { FlutterJNI mockFlutterJNI = mock(FlutterJNI.class); DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, editable, null, mockFlutterJNI); @@ -441,7 +439,7 @@ public void testPerformPrivateCommand_dataIsFloatArray() throws JSONException { public void testSendKeyEvent_shiftKeyUpCancelsSelection() { int selStart = 5; int selEnd = 10; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftKeyUp = new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT); @@ -455,7 +453,7 @@ public void testSendKeyEvent_shiftKeyUpCancelsSelection() { @Test public void testSendKeyEvent_leftKeyMovesCaretLeft() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -469,7 +467,7 @@ public void testSendKeyEvent_leftKeyMovesCaretLeft() { @Test public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { int selStart = 75; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -612,7 +610,7 @@ public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { int selStart = 5; int selEnd = 40; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -626,7 +624,7 @@ public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { @Test public void testSendKeyEvent_shiftLeftKeyStartsSelectionLeft() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftLeftKeyDown = @@ -642,7 +640,7 @@ public void testSendKeyEvent_shiftLeftKeyStartsSelectionLeft() { @Test public void testSendKeyEvent_rightKeyMovesCaretRight() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -660,7 +658,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexRegion() { // three region indicators, and the final seventh character should be // considered to be on its own because it has no partner. String SAMPLE_REGION_TEXT = "🇷🇷🇷🇷🇷🇷🇷"; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_REGION_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_REGION_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -694,7 +692,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexRegion() { @Test public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { int selStart = 0; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -830,7 +828,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { public void testSendKeyEvent_rightKeyExtendsSelectionRight() { int selStart = 5; int selEnd = 40; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -844,7 +842,7 @@ public void testSendKeyEvent_rightKeyExtendsSelectionRight() { @Test public void testSendKeyEvent_shiftRightKeyStartsSelectionRight() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftRightKeyDown = @@ -860,7 +858,7 @@ public void testSendKeyEvent_shiftRightKeyStartsSelectionRight() { @Test public void testSendKeyEvent_upKeyMovesCaretUp() { int selStart = SAMPLE_TEXT.indexOf('\n') + 4; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent upKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP); @@ -875,7 +873,7 @@ public void testSendKeyEvent_upKeyMovesCaretUp() { @Test public void testSendKeyEvent_downKeyMovesCaretDown() { int selStart = 4; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN); @@ -890,7 +888,7 @@ public void testSendKeyEvent_downKeyMovesCaretDown() { @Test public void testMethod_getExtractedText() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); ExtractedText extractedText = adaptor.getExtractedText(null, 0); @@ -900,91 +898,10 @@ public void testMethod_getExtractedText() { assertEquals(extractedText.selectionEnd, selStart); } - @Test - public void inputConnectionAdaptor_RepeatFilter() throws NullPointerException { - View testView = new View(RuntimeEnvironment.application); - FlutterJNI mockFlutterJni = mock(FlutterJNI.class); - DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); - int inputTargetId = 0; - TestTextInputChannel textInputChannel = new TestTextInputChannel(dartExecutor); - Editable mEditable = Editable.Factory.getInstance().newEditable(""); - Editable spyEditable = spy(mEditable); - EditorInfo outAttrs = new EditorInfo(); - outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; - - InputConnectionAdaptor inputConnectionAdaptor = - new InputConnectionAdaptor( - testView, inputTargetId, textInputChannel, spyEditable, outAttrs); - - inputConnectionAdaptor.beginBatchEdit(); - assertEquals(textInputChannel.updateEditingStateInvocations, 0); - inputConnectionAdaptor.setComposingText("I do not fear computers. I fear the lack of them.", 1); - assertEquals(textInputChannel.text, null); - assertEquals(textInputChannel.updateEditingStateInvocations, 0); - inputConnectionAdaptor.endBatchEdit(); - assertEquals(textInputChannel.updateEditingStateInvocations, 1); - assertEquals(textInputChannel.text, "I do not fear computers. I fear the lack of them."); - - inputConnectionAdaptor.beginBatchEdit(); - assertEquals(textInputChannel.updateEditingStateInvocations, 1); - inputConnectionAdaptor.endBatchEdit(); - assertEquals(textInputChannel.updateEditingStateInvocations, 1); - - inputConnectionAdaptor.beginBatchEdit(); - assertEquals(textInputChannel.text, "I do not fear computers. I fear the lack of them."); - assertEquals(textInputChannel.updateEditingStateInvocations, 1); - inputConnectionAdaptor.setSelection(3, 4); - assertEquals(textInputChannel.updateEditingStateInvocations, 1); - assertEquals(textInputChannel.selectionStart, 49); - assertEquals(textInputChannel.selectionEnd, 49); - inputConnectionAdaptor.endBatchEdit(); - assertEquals(textInputChannel.updateEditingStateInvocations, 2); - assertEquals(textInputChannel.selectionStart, 3); - assertEquals(textInputChannel.selectionEnd, 4); - } - - @Test - public void inputConnectionAdaptor_skipCallsAfterEditingValueUpdateFromFramework() { - View testView = new View(RuntimeEnvironment.application); - FlutterJNI mockFlutterJni = mock(FlutterJNI.class); - DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); - int inputTargetId = 0; - TestTextInputChannel textInputChannel = new TestTextInputChannel(dartExecutor); - Editable mEditable = Editable.Factory.getInstance().newEditable(""); - EditorInfo outAttrs = new EditorInfo(); - outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; - - InputConnectionAdaptor inputConnectionAdaptor = - new InputConnectionAdaptor(testView, inputTargetId, textInputChannel, mEditable, outAttrs); - - inputConnectionAdaptor.setComposingText("initial text", 1); - assertEquals(textInputChannel.text, "initial text"); - // Calls updateEditingState. - inputConnectionAdaptor.beginBatchEdit(); - inputConnectionAdaptor.endBatchEdit(); - assertEquals(textInputChannel.text, "initial text"); - // Do send update to the framework. - assertEquals(textInputChannel.updateEditingStateInvocations, 1); - - // Change the internal state and pretend this is from the framework. - mEditable.replace(0, mEditable.length(), "updated text from the framework"); - inputConnectionAdaptor.didUpdateEditingValue(); - // Does not send update because it is the same as the state we just received. - assertEquals(textInputChannel.updateEditingStateInvocations, 1); - - mEditable.replace(0, mEditable.length(), "yet another updated text from the framework"); - // Calls updateEditingState. - inputConnectionAdaptor.beginBatchEdit(); - inputConnectionAdaptor.endBatchEdit(); - // Does send update because it is the same as the state we just received. - assertEquals(textInputChannel.text, "yet another updated text from the framework"); - assertEquals(textInputChannel.updateEditingStateInvocations, 2); - } - @Test public void testSendKeyEvent_delKeyDeletesBackward() { int selStart = 29; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_RTL_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_RTL_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); @@ -1005,7 +922,7 @@ public void testSendKeyEvent_delKeyDeletesBackward() { @Test public void testSendKeyEvent_delKeyDeletesBackwardComplexEmojis() { int selStart = 75; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); @@ -1146,7 +1063,7 @@ public void testSendKeyEvent_delKeyDeletesBackwardComplexEmojis() { @Test public void testDoesNotConsumeBackButton() { - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); FakeKeyEvent keyEvent = new FakeKeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK); @@ -1180,19 +1097,22 @@ public void testDoesNotConsumeBackButton() { private static final String SAMPLE_RTL_TEXT = "متن ساختگی" + "\nبرای تستfor test😊"; - private static Editable sampleEditable(int selStart, int selEnd) { - SpannableStringBuilder sample = new SpannableStringBuilder(SAMPLE_TEXT); + private static ListenableEditingState sampleEditable(int selStart, int selEnd) { + ListenableEditingState sample = new ListenableEditingState(null); + sample.replace(0, 0, SAMPLE_TEXT); Selection.setSelection(sample, selStart, selEnd); return sample; } - private static Editable sampleEditable(int selStart, int selEnd, String text) { - SpannableStringBuilder sample = new SpannableStringBuilder(text); + private static ListenableEditingState sampleEditable(int selStart, int selEnd, String text) { + ListenableEditingState sample = new ListenableEditingState(null); + sample.replace(0, 0, text); Selection.setSelection(sample, selStart, selEnd); return sample; } - private static InputConnectionAdaptor sampleInputConnectionAdaptor(Editable editable) { + private static InputConnectionAdaptor sampleInputConnectionAdaptor( + ListenableEditingState editable) { View testView = new View(RuntimeEnvironment.application); int client = 0; TextInputChannel textInputChannel = mock(TextInputChannel.class); 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 d4e70e8172c39..d5f7b01b57ee3 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -22,6 +22,8 @@ import android.os.Build; import android.os.Bundle; import android.provider.Settings; +import android.text.InputType; +import android.text.Selection; import android.util.SparseIntArray; import android.view.KeyEvent; import android.view.View; @@ -154,13 +156,104 @@ public void setTextInputEditingState_doesNotInvokeUpdateEditingState() { textInputPlugin.setTextInputEditingState( testView, - new TextInputChannel.TextEditState("more update from the framwork", 1, 1, -1, -1)); + new TextInputChannel.TextEditState("more update from the framework", 1, 2, -1, -1)); - assertTrue(textInputPlugin.getEditable().toString().equals("more update from the framwork")); + assertTrue(textInputPlugin.getEditable().toString().equals("more update from the framework")); verify(textInputChannel, times(0)) .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); } + @Test + public void inputConnectionAdaptor_RepeatFilter() throws NullPointerException { + // Initialize a general TextInputPlugin. + InputMethodSubtype inputMethodSubtype = mock(InputMethodSubtype.class); + TestImm testImm = + Shadow.extract( + RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE)); + testImm.setCurrentInputMethodSubtype(inputMethodSubtype); + View testView = new View(RuntimeEnvironment.application); + EditorInfo outAttrs = new EditorInfo(); + outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; + TextInputChannel textInputChannel = spy(new TextInputChannel(mock(DartExecutor.class))); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + + // Change InputTarget to FRAMEWORK_CLIENT. + textInputPlugin.setTextInputClient( + 0, + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + new TextInputChannel.InputType(TextInputChannel.TextInputType.TEXT, false, false), + null, + null, + 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, -1, -1)); + verify(textInputChannel, times(0)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + + InputConnectionAdaptor inputConnectionAdaptor = + (InputConnectionAdaptor) textInputPlugin.createInputConnection(testView, outAttrs); + + inputConnectionAdaptor.beginBatchEdit(); + verify(textInputChannel, times(0)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + inputConnectionAdaptor.setComposingText("I do not fear computers. I fear the lack of them.", 1); + verify(textInputChannel, times(0)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + inputConnectionAdaptor.endBatchEdit(); + verify(textInputChannel, times(1)) + .updateEditingState( + anyInt(), + eq("I do not fear computers. I fear the lack of them."), + eq(49), + eq(49), + eq(0), + eq(49)); + + inputConnectionAdaptor.beginBatchEdit(); + + verify(textInputChannel, times(1)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + + inputConnectionAdaptor.endBatchEdit(); + + verify(textInputChannel, times(1)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + + inputConnectionAdaptor.beginBatchEdit(); + + verify(textInputChannel, times(1)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + + inputConnectionAdaptor.setSelection(3, 4); + assertEquals(Selection.getSelectionStart(textInputPlugin.getEditable()), 3); + assertEquals(Selection.getSelectionEnd(textInputPlugin.getEditable()), 4); + + verify(textInputChannel, times(1)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + + verify(textInputChannel, times(1)) + .updateEditingState(anyInt(), any(), anyInt(), anyInt(), anyInt(), anyInt()); + + inputConnectionAdaptor.endBatchEdit(); + + verify(textInputChannel, times(1)) + .updateEditingState( + anyInt(), + eq("I do not fear computers. I fear the lack of them."), + eq(3), + eq(4), + eq(0), + eq(49)); + } + @Test public void setTextInputEditingState_doesNotRestartWhenTextIsIdentical() { // Initialize a general TextInputPlugin. From 420568fe0793569bb2c1e33a8aa6fbce5a68f568 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 30 Oct 2020 00:53:00 -0700 Subject: [PATCH 08/18] update --- shell/platform/android/BUILD.gn | 1 + .../editing/InputConnectionAdaptor.java | 30 +- .../editing/ListenableEditingState.java | 115 ++++--- .../plugin/editing/TextInputPlugin.java | 41 +-- shell/platform/android/test/README.md | 7 +- .../test/io/flutter/FlutterTestSuite.java | 2 + .../editing/InputConnectionAdaptorTest.java | 165 +++++++++- .../editing/ListenableEditingStateTest.java | 299 ++++++++++++++++++ .../plugin/editing/TextInputPluginTest.java | 243 +++++++++++++- 9 files changed, 796 insertions(+), 107 deletions(-) create mode 100644 shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 9f080f145b8f5..62b24fcd1ecda 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -473,6 +473,7 @@ action("robolectric_tests") { "test/io/flutter/plugin/common/StandardMessageCodecTest.java", "test/io/flutter/plugin/common/StandardMethodCodecTest.java", "test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java", + "test/io/flutter/plugin/editing/ListenableEditingStateTest.java", "test/io/flutter/plugin/editing/TextInputPluginTest.java", "test/io/flutter/plugin/localization/LocalizationPluginTest.java", "test/io/flutter/plugin/mouse/MouseCursorPluginTest.java", diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 401b11acd2684..ba4da774fa3ce 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -86,13 +86,16 @@ public InputConnectionAdaptor( this(view, client, textInputChannel, editable, editorInfo, new FlutterJNI()); } - private ExtractedText getExtractedText() { + private ExtractedText getExtractedText(ExtractedTextRequest request) { mExtractedText.startOffset = 0; mExtractedText.partialStartOffset = -1; mExtractedText.partialEndOffset = -1; mExtractedText.selectionStart = mEditable.getSelecionStart(); mExtractedText.selectionEnd = mEditable.getSelecionEnd(); - mExtractedText.text = mEditable.toString(); + mExtractedText.text = + request == null || (request.flags & GET_TEXT_WITH_STYLES) == 0 + ? mEditable.toString() + : mEditable; return mExtractedText; } @@ -145,7 +148,7 @@ public boolean commitText(CharSequence text, int newCursorPosition) { @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - if (Selection.getSelectionStart(mEditable) == -1) { + if (mEditable.getSelecionStart() == -1) { return true; } @@ -161,7 +164,7 @@ public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLeng @Override public boolean setComposingRegion(int start, int end) { - Log.i("flutter", "engine: set CR: " + String.valueOf(start) + " - " + String.valueOf(end)); + Log.v("flutter", "engine: set CR: " + String.valueOf(start) + " - " + String.valueOf(end)); final boolean result = super.setComposingRegion(start, end); return result; } @@ -169,18 +172,20 @@ public boolean setComposingRegion(int start, int end) { @Override public boolean setComposingText(CharSequence text, int newCursorPosition) { boolean result; - Log.i("flutter", "engine: set CT: " + text + ", " + String.valueOf(newCursorPosition)); + Log.v("flutter", "engine: set CT: " + text + ", " + String.valueOf(newCursorPosition)); + beginBatchEdit(); if (text.length() == 0) { result = super.commitText(text, newCursorPosition); } else { result = super.setComposingText(text, newCursorPosition); } + endBatchEdit(); return result; } @Override public boolean finishComposingText() { - Log.i("flutter", "engine: finish composing"); + Log.v("flutter", "engine: finish composing"); final boolean result = super.finishComposingText(); return result; } @@ -196,7 +201,7 @@ public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { // Enables text monitoring if the relevant flag is set. See // InputConnectionAdaptor#didChangeEditingState. mExtractRequest = textMonitor ? request : null; - return getExtractedText(); + return getExtractedText(request); } @Override @@ -208,8 +213,13 @@ public boolean requestCursorUpdates(int cursorUpdateMode) { mImm.updateCursorAnchorInfo(mFlutterView, getCursorAnchorInfo()); } + final boolean updated = (cursorUpdateMode & CURSOR_UPDATE_MONITOR) != 0; + if (updated != mMonitorCursorUpdate) { + Log.d(TAG, "The input method toggled cursor monitoring " + (updated ? "on" : "off")); + } + // Enables cursor monitoring. See InputConnectionAdaptor#didChangeEditingState. - mMonitorCursorUpdate = (cursorUpdateMode & CURSOR_UPDATE_MONITOR) != 0; + mMonitorCursorUpdate = updated; return true; } @@ -487,12 +497,12 @@ public void didChangeEditingState( return; } if (mExtractRequest != null) { - mImm.updateExtractedText(mFlutterView, mExtractRequest.token, getExtractedText()); + mImm.updateExtractedText( + mFlutterView, mExtractRequest.token, getExtractedText(mExtractRequest)); } if (mMonitorCursorUpdate) { final CursorAnchorInfo info = getCursorAnchorInfo(); mImm.updateCursorAnchorInfo(mFlutterView, info); - Log.v(TAG, "update CursorAnchorInfo: " + info.toString()); } } // -------- End: ListenableEditingState watcher implementation ------- diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index 1132cb19b0a36..cbeb5a46acf1a 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -16,8 +16,11 @@ void didChangeEditingState( boolean textChanged, boolean selectionChanged, boolean composingRegionChanged); } + private static final String TAG = "flutter"; + private int mBatchEditNestDepth = 0; private ArrayList mListeners = new ArrayList<>(); + private ArrayList mPendingListeners = new ArrayList<>(); private String mToStringCache; @@ -27,61 +30,74 @@ void didChangeEditingState( private int mComposingStartWhenBeginBatchEdit; private int mComposingEndWhenBeginBatchEdit; - private static BaseInputConnection mDummyConnection; + private BaseInputConnection mDummyConnection; - public ListenableEditingState(TextInputChannel.TextEditState configuration) { + public ListenableEditingState(TextInputChannel.TextEditState configuration, View view) { super(); if (configuration != null) { setEditingState(configuration); } + + Editable self = this; + mDummyConnection = + new BaseInputConnection(view, true) { + @Override + public Editable getEditable() { + return self; + } + }; } public void beginBatchEdit() { if (mBatchEditNestDepth == 0 && !mListeners.isEmpty()) { mTextWhenBeginBatchEdit = toString(); - mSelectionStartWhenBeginBatchEdit = Selection.getSelectionStart(this); - mSelectionEndWhenBeginBatchEdit = Selection.getSelectionEnd(this); - mComposingStartWhenBeginBatchEdit = BaseInputConnection.getComposingSpanStart(this); - mComposingEndWhenBeginBatchEdit = BaseInputConnection.getComposingSpanEnd(this); + mSelectionStartWhenBeginBatchEdit = getSelecionStart(); + mSelectionEndWhenBeginBatchEdit = getSelecionEnd(); + mComposingStartWhenBeginBatchEdit = getComposingStart(); + mComposingEndWhenBeginBatchEdit = getComposingEnd(); } mBatchEditNestDepth++; } public void endBatchEdit() { mBatchEditNestDepth--; - if (mBatchEditNestDepth == 0 && !mListeners.isEmpty()) { - final boolean textChanged = toString().equals(mTextWhenBeginBatchEdit); + if (mBatchEditNestDepth != 0) { + mBatchEditNestDepth = mBatchEditNestDepth < 0 ? 0 : mBatchEditNestDepth; + return; + } + + for (final EditingStateWatcher watcher : mPendingListeners) { + watcher.didChangeEditingState(true, true, true); + } + + if (!mListeners.isEmpty()) { + Log.v(TAG, "didFinishBatchEdit with " + String.valueOf(mListeners.size()) + " listener(s)"); + final boolean textChanged = !toString().equals(mTextWhenBeginBatchEdit); final boolean selectionChanged = - mSelectionStartWhenBeginBatchEdit == Selection.getSelectionStart(this) - && mSelectionEndWhenBeginBatchEdit == Selection.getSelectionEnd(this); + mSelectionStartWhenBeginBatchEdit != getSelecionStart() + || mSelectionEndWhenBeginBatchEdit != getSelecionEnd(); final boolean composingRegionChanged = - mComposingStartWhenBeginBatchEdit == BaseInputConnection.getComposingSpanStart(this) - && mComposingEndWhenBeginBatchEdit == BaseInputConnection.getComposingSpanEnd(this); - for (int i = 0; i < mListeners.size(); i++) { - mListeners - .get(i) - .didChangeEditingState(textChanged, selectionChanged, composingRegionChanged); + mComposingStartWhenBeginBatchEdit != getComposingStart() + || mComposingEndWhenBeginBatchEdit != getComposingEnd(); + if (textChanged || selectionChanged || composingRegionChanged) { + for (int i = 0; i < mListeners.size(); i++) { + mListeners + .get(i) + .didChangeEditingState(textChanged, selectionChanged, composingRegionChanged); + } } } + + mListeners.addAll(mPendingListeners); + mPendingListeners.clear(); } - public static void setComposingRange(Editable editable, int composingStart, int composingEnd) { + public void setComposingRange(int composingStart, int composingEnd) { if (composingStart < 0 || composingStart >= composingEnd) { - BaseInputConnection.removeComposingSpans(editable); - return; - } - - if (mDummyConnection == null || mDummyConnection.getEditable() != editable) { - mDummyConnection = - new BaseInputConnection(new View(null), true) { - @Override - public Editable getEditable() { - return editable; - } - }; + BaseInputConnection.removeComposingSpans(this); + } else { + mDummyConnection.setComposingRegion(composingStart, composingEnd); } - - mDummyConnection.setComposingRegion(composingStart, composingEnd); } public void setEditingState(TextInputChannel.TextEditState newState) { @@ -93,16 +109,27 @@ public void setEditingState(TextInputChannel.TextEditState newState) { } else { Selection.removeSelection(this); } - setComposingRange(this, newState.composingStart, newState.composingEnd); + setComposingRange(newState.composingStart, newState.composingEnd); endBatchEdit(); } public void addEditingStateListener(EditingStateWatcher listener) { - mListeners.add(listener); + // It is possible for a listener to get added during a batch edit. When that happens we always + // notify the new listeners. + // This does not check if the listener is already in the list of existing listeners. + if (mBatchEditNestDepth > 0) { + Log.w(TAG, "a listener was added to EditingState while a batch edit was in progress"); + mPendingListeners.add(listener); + } else { + mListeners.add(listener); + } } public void removeEditingStateListener(EditingStateWatcher listener) { mListeners.remove(listener); + if (mBatchEditNestDepth > 0) { + mPendingListeners.remove(listener); + } } @Override @@ -110,8 +137,7 @@ public SpannableStringBuilder replace( int start, int end, CharSequence tb, int tbstart, int tbend) { boolean textChanged = end - start != tbend - tbstart; for (int i = 0; i < end - start && !textChanged; i++) { - textChanged |= charAt(start + i) == tb.charAt(tbstart + i); - if (textChanged) break; + textChanged |= charAt(start + i) != tb.charAt(tbstart + i); } if (textChanged) { mToStringCache = null; @@ -130,9 +156,9 @@ public SpannableStringBuilder replace( Log.i("flutter", editable.toString()); Log.i("flutter", String.valueOf(getSelecionStart()) + " , " + String.valueOf(getSelecionEnd())); final boolean selectionChanged = - getSelecionStart() == selectionStart && getSelecionEnd() == selectionEnd; + getSelecionStart() != selectionStart || getSelecionEnd() != selectionEnd; final boolean composingRegionChanged = - getComposingStart() == composingStart && getComposingEnd() == composingEnd; + getComposingStart() != composingStart || getComposingEnd() != composingEnd; if (textChanged || selectionChanged || composingRegionChanged) { for (int i = 0; i < mListeners.size(); i++) { mListeners @@ -143,21 +169,6 @@ public SpannableStringBuilder replace( return editable; } - @Override - public void removeSpan(Object what) { - super.removeSpan(what); - } - - @Override - public void clearSpans() { - super.clearSpans(); - } - - @Override - public void setSpan(Object what, int start, int end, int flags) { - super.setSpan(what, start, end, flags); - } - public final int getSelecionStart() { return Selection.getSelectionStart(this); } diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 95720e947528e..9e02598389be3 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -214,6 +214,7 @@ public void unlockPlatformViewInputConnection() { public void destroy() { platformViewsController.detachTextInputPlugin(); textInputChannel.setTextInputMethodHandler(null); + notifyViewExited(); if (mEditable != null) { mEditable.removeEditingStateListener(this); } @@ -405,6 +406,8 @@ public void notifyValueChanged(String newValue) { @VisibleForTesting void setTextInputClient(int client, TextInputChannel.Configuration configuration) { + // Call notifyViewExited on the previous field. + notifyViewExited(); inputTarget = new InputTarget(InputTarget.Type.FRAMEWORK_CLIENT, client); if (mEditable != null) { @@ -412,7 +415,7 @@ void setTextInputClient(int client, TextInputChannel.Configuration configuration } mEditable = new ListenableEditingState( - configuration.autofill != null ? configuration.autofill.editState : null); + configuration.autofill != null ? configuration.autofill.editState : null, mView); updateAutofillConfigurationIfNeeded(configuration); // setTextInputClient will be followed by a call to setTextInputEditingState. @@ -518,7 +521,8 @@ private boolean isRestartAlwaysRequired() { return keyboardName.contains("Samsung"); } - private void clearTextInputClient() { + @VisibleForTesting + void clearTextInputClient() { if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { // Focus changes in the framework tree have no guarantees on the order focus nodes are // notified. A node @@ -543,9 +547,10 @@ private void clearTextInputClient() { return; } mEditable.removeEditingStateListener(this); + notifyViewExited(); + updateAutofillConfigurationIfNeeded(null); inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); unlockPlatformViewInputConnection(); - notifyViewExited(); lastClientRect = null; } @@ -597,27 +602,6 @@ public void didChangeEditingState( // Skip if we're currently setting if (!skipFrameworkUpdate) { Log.v(TAG, "send EditingState to flutter: " + mEditable.toString()); - Log.i(TAG, mEditable.toString() + " == " + mLastKnownFrameworkTextEditingState.text); - Log.i( - TAG, - String.valueOf(selectionStart) - + " == " - + String.valueOf(mLastKnownFrameworkTextEditingState.selectionStart)); - Log.i( - TAG, - String.valueOf(selectionEnd) - + " == " - + String.valueOf(mLastKnownFrameworkTextEditingState.selectionEnd)); - Log.i( - TAG, - String.valueOf(composingStart) - + " == " - + String.valueOf(mLastKnownFrameworkTextEditingState.composingStart)); - Log.i( - TAG, - String.valueOf(composingEnd) - + " == " - + String.valueOf(mLastKnownFrameworkTextEditingState.composingEnd)); textInputChannel.updateEditingState( inputTarget.id, mEditable.toString(), @@ -642,16 +626,15 @@ private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; } - notifyViewExited(); - this.configuration = configuration; - final TextInputChannel.Configuration[] configurations = configuration.fields; - if (configuration.autofill == null) { + this.configuration = configuration; + if (configuration == null || configuration.autofill == null) { // Disables autofill if the configuration doesn't have an autofill field. mAutofillConfigurations = null; return; } + final TextInputChannel.Configuration[] configurations = configuration.fields; mAutofillConfigurations = new SparseArray<>(); if (configurations == null) { @@ -704,7 +687,7 @@ public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags 0, lastClientRect.width(), lastClientRect.height()); - child.setAutofillValue(AutofillValue.forText(mEditable.toString())); + child.setAutofillValue(AutofillValue.forText(mEditable)); } else { child.setDimens(0, 0, 0, 0, 1, 1); child.setAutofillValue(AutofillValue.forText(autofill.editState.text)); diff --git a/shell/platform/android/test/README.md b/shell/platform/android/test/README.md index 9817c846587a5..2c0f17568c064 100644 --- a/shell/platform/android/test/README.md +++ b/shell/platform/android/test/README.md @@ -14,9 +14,10 @@ integration tests in other repos. `shell/platform/android/**test**/io/flutter/util/Preconditions**Test**.java`. 2. Add your file to the `sources` of the `robolectric_tests` build target in `/shell/platform/android/BUILD.gn`. This compiles the test class into the - test jar. -3. Add your class to the `@SuiteClasses` annotation in `FlutterTestSuite.java`. - This makes sure the test is actually executed at run time. + test jar. +3. Add your class to the `@SuiteClasses` annotation in `FlutterTestSuite.java` + and import the test class. This makes sure the test is actually executed at + run time. 4. Write your test. 5. Build and run with `testing/run_tests.py [--type=java] [--java-filter=]`. diff --git a/shell/platform/android/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java index f440f736589e1..635b6680475d0 100644 --- a/shell/platform/android/test/io/flutter/FlutterTestSuite.java +++ b/shell/platform/android/test/io/flutter/FlutterTestSuite.java @@ -30,6 +30,7 @@ import io.flutter.plugin.common.StandardMessageCodecTest; import io.flutter.plugin.common.StandardMethodCodecTest; import io.flutter.plugin.editing.InputConnectionAdaptorTest; +import io.flutter.plugin.editing.ListenableEditingStateTest; import io.flutter.plugin.editing.TextInputPluginTest; import io.flutter.plugin.mouse.MouseCursorPluginTest; import io.flutter.plugin.platform.PlatformPluginTest; @@ -70,6 +71,7 @@ FlutterViewTest.class, InputConnectionAdaptorTest.class, KeyEventChannelTest.class, + ListenableEditingStateTest.class, LocalizationPluginTest.class, MouseCursorPluginTest.class, PlatformChannelTest.class, diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index 62bc064f8ae97..0b5f7043328a5 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyInt; @@ -14,15 +15,21 @@ import static org.mockito.Mockito.when; import android.content.ClipboardManager; +import android.content.Context; import android.content.res.AssetManager; import android.os.Bundle; import android.text.Emoji; import android.text.InputType; import android.text.Selection; +import android.text.SpannableStringBuilder; import android.view.KeyEvent; import android.view.View; +import android.view.inputmethod.CursorAnchorInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.embedding.engine.systemchannels.TextInputChannel; @@ -39,9 +46,15 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowClipboardManager; +import org.robolectric.shadows.ShadowInputMethodManager; -@Config(manifest = Config.NONE, shadows = ShadowClipboardManager.class) +@Config( + manifest = Config.NONE, + shadows = {ShadowClipboardManager.class, InputConnectionAdaptorTest.TestImm.class}) @RunWith(RobolectricTestRunner.class) public class InputConnectionAdaptorTest { // Verifies the method and arguments for a captured method call. @@ -66,7 +79,7 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); int inputTargetId = 0; TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); - ListenableEditingState mEditable = new ListenableEditingState(null); + ListenableEditingState mEditable = new ListenableEditingState(null, testView); ListenableEditingState spyEditable = spy(mEditable); EditorInfo outAttrs = new EditorInfo(); outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; @@ -888,6 +901,7 @@ public void testSendKeyEvent_downKeyMovesCaretDown() { @Test public void testMethod_getExtractedText() { int selStart = 5; + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); @@ -900,11 +914,87 @@ public void testMethod_getExtractedText() { @Test public void testExtractedText_monitoring() { - int selStart = 5; - ListenableEditingState editable = sampleEditable(selStart, selStart); - InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); + ListenableEditingState editable = sampleEditable(5, 5); + View testView = new View(RuntimeEnvironment.application); + InputConnectionAdaptor adaptor = + new InputConnectionAdaptor( + testView, 1, mock(TextInputChannel.class), editable, new EditorInfo()); + TestImm testImm = + Shadow.extract( + RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE)); + + testImm.resetStates(); + + ExtractedTextRequest request = new ExtractedTextRequest(); + request.token = 123; + + ExtractedText extractedText = adaptor.getExtractedText(request, 0); + assertEquals(5, extractedText.selectionStart); + assertEquals(5, extractedText.selectionEnd); + assertFalse(extractedText.text instanceof SpannableStringBuilder); + + // Move the cursor. Should not report extracted text. + adaptor.setSelection(2, 3); + assertNull(testImm.lastExtractedText); + + // Now request monitoring, and update the request text flag. + request.flags = InputConnection.GET_TEXT_WITH_STYLES; + extractedText = adaptor.getExtractedText(request, InputConnection.GET_EXTRACTED_TEXT_MONITOR); + assertEquals(2, extractedText.selectionStart); + assertEquals(3, extractedText.selectionEnd); + assertTrue(extractedText.text instanceof SpannableStringBuilder); + + adaptor.setSelection(3, 5); + assertEquals(3, testImm.lastExtractedText.selectionStart); + assertEquals(5, testImm.lastExtractedText.selectionEnd); + assertTrue(testImm.lastExtractedText.text instanceof SpannableStringBuilder); + + // Stop monitoring. + testImm.resetStates(); + extractedText = adaptor.getExtractedText(request, 0); + assertEquals(3, extractedText.selectionStart); + assertEquals(5, extractedText.selectionEnd); + assertTrue(extractedText.text instanceof SpannableStringBuilder); + + adaptor.setSelection(1, 3); + assertNull(testImm.lastExtractedText); + } - ExtractedText extractedText = adaptor.getExtractedText(null, 0); + @Test + public void testCursorAnchorInfo() { + ListenableEditingState editable = sampleEditable(5, 5); + View testView = new View(RuntimeEnvironment.application); + InputConnectionAdaptor adaptor = + new InputConnectionAdaptor( + testView, 1, mock(TextInputChannel.class), editable, new EditorInfo()); + TestImm testImm = + Shadow.extract( + RuntimeEnvironment.application.getSystemService(Context.INPUT_METHOD_SERVICE)); + + testImm.resetStates(); + + // Monitoring only. Does not send update immediately. + adaptor.requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR); + assertNull(testImm.lastCursorAnchorInfo); + + // Monitor selection changes. + adaptor.setSelection(0, 1); + CursorAnchorInfo cursorAnchorInfo = testImm.lastCursorAnchorInfo; + assertEquals(0, cursorAnchorInfo.getSelectionStart()); + assertEquals(1, cursorAnchorInfo.getSelectionEnd()); + + // Turn monitoring off. + testImm.resetStates(); + assertNull(testImm.lastCursorAnchorInfo); + adaptor.requestCursorUpdates(InputConnection.CURSOR_UPDATE_IMMEDIATE); + cursorAnchorInfo = testImm.lastCursorAnchorInfo; + assertEquals(0, cursorAnchorInfo.getSelectionStart()); + assertEquals(1, cursorAnchorInfo.getSelectionEnd()); + + // No more updates. + testImm.resetStates(); + adaptor.setSelection(1, 3); + assertNull(testImm.lastCursorAnchorInfo); } @Test @@ -1107,14 +1197,16 @@ public void testDoesNotConsumeBackButton() { private static final String SAMPLE_RTL_TEXT = "متن ساختگی" + "\nبرای تستfor test😊"; private static ListenableEditingState sampleEditable(int selStart, int selEnd) { - ListenableEditingState sample = new ListenableEditingState(null); + ListenableEditingState sample = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); sample.replace(0, 0, SAMPLE_TEXT); Selection.setSelection(sample, selStart, selEnd); return sample; } private static ListenableEditingState sampleEditable(int selStart, int selEnd, String text) { - ListenableEditingState sample = new ListenableEditingState(null); + ListenableEditingState sample = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); sample.replace(0, 0, text); Selection.setSelection(sample, selStart, selEnd); return sample; @@ -1175,4 +1267,61 @@ public void updateEditingState( updateEditingStateInvocations++; } } + + @Implements(InputMethodManager.class) + public static class TestImm extends ShadowInputMethodManager { + public static int empty = -999; + // private InputMethodSubtype currentInputMethodSubtype; + CursorAnchorInfo lastCursorAnchorInfo; + int lastExtractedTextToken = empty; + ExtractedText lastExtractedText; + + int lastSelectionStart = empty; + int lastSelectionEnd = empty; + int lastCandidatesStart = empty; + int lastCandidatesEnd = empty; + + public TestImm() {} + + // @Implementation + // public InputMethodSubtype getCurrentInputMethodSubtype() { + // return currentInputMethodSubtype; + // } + + // public void setCurrentInputMethodSubtype(InputMethodSubtype inputMethodSubtype) { + // this.currentInputMethodSubtype = inputMethodSubtype; + // } + + @Implementation + public void updateCursorAnchorInfo(View view, CursorAnchorInfo cursorAnchorInfo) { + lastCursorAnchorInfo = cursorAnchorInfo; + } + + @Implementation + public void updateExtractedText(View view, int token, ExtractedText text) { + lastExtractedTextToken = token; + lastExtractedText = text; + } + + @Implementation + public void updateSelection( + View view, int selStart, int selEnd, int candidatesStart, int candidatesEnd) { + lastSelectionStart = selStart; + lastSelectionEnd = selEnd; + lastCandidatesStart = candidatesStart; + lastCandidatesEnd = candidatesEnd; + } + + public void resetStates() { + lastExtractedText = null; + lastExtractedTextToken = empty; + + lastSelectionStart = empty; + lastSelectionEnd = empty; + lastCandidatesStart = empty; + lastCandidatesEnd = empty; + + lastCursorAnchorInfo = null; + } + } } diff --git a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java new file mode 100644 index 0000000000000..b6101684dff76 --- /dev/null +++ b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java @@ -0,0 +1,299 @@ +package io.flutter.plugin.editing; + +import android.text.Editable; +import android.text.Selection; +import android.view.View; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import io.flutter.embedding.engine.systemchannels.TextInputChannel; + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner.class) +public class ListenableEditingStateTest { + private BaseInputConnection getTestInputConnection(View view, Editable mEditable) { + new View(RuntimeEnvironment.application); + return new BaseInputConnection(view, true) { + @Override + public Editable getEditable() { return mEditable; } + }; + } + + // -------- Start: Test BatchEditing ------- + @Test + public void testBatchEditing() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final BaseInputConnection inputConnection = getTestInputConnection(testView, editingState); + + editingState.addEditingStateListener(listener); + + editingState.replace(0, editingState.length(), "update"); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertFalse(listener.selectionChanged); + assertFalse(listener.composingRegionChanged); + + assertEquals(-1, editingState.getSelecionStart()); + assertEquals(-1, editingState.getSelecionEnd()); + + listener.reset(); + + // Batch edit depth = 1. + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "update1"); + assertFalse(listener.called); + // Batch edit depth = 2. + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "update2"); + inputConnection.setComposingRegion(0, editingState.length()); + assertFalse(listener.called); + // Batch edit depth = 1. + editingState.endBatchEdit(); + assertFalse(listener.called); + + // Batch edit depth = 2. + editingState.beginBatchEdit(); + assertFalse(listener.called); + inputConnection.setSelection(0, 0); + assertFalse(listener.called); + // Batch edit depth = 1. + editingState.endBatchEdit(); + assertFalse(listener.called); + + // Remove composing region. + inputConnection.finishComposingText(); + + // Batch edit depth = 0. Last endBatchEdit. + editingState.endBatchEdit(); + + // Now notify the listener. + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertFalse(listener.composingRegionChanged); + } + + @Test + public void testBatchingEditing_callEndBeforeBegin() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + editingState.addEditingStateListener(listener); + + editingState.endBatchEdit(); + assertFalse(listener.called); + + editingState.replace(0, editingState.length(), "text"); + assertTrue(listener.called); + assertTrue(listener.textChanged); + + listener.reset(); + // Does not disrupt the followup events. + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "more text"); + assertFalse(listener.called); + editingState.endBatchEdit(); + assertTrue(listener.called); + } + + @Test + public void testBatchingEditing_addListenerDuringBatchEdit() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + + editingState.beginBatchEdit(); + editingState.addEditingStateListener(listener); + editingState.replace(0, editingState.length(), "update"); + editingState.endBatchEdit(); + editingState.removeEditingStateListener(listener); + + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertTrue(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + listener.reset(); + + // Now remove before endBatchEdit(); + editingState.beginBatchEdit(); + editingState.addEditingStateListener(listener); + editingState.replace(0, editingState.length(), "update"); + editingState.removeEditingStateListener(listener); + editingState.endBatchEdit(); + + assertFalse(listener.called); + } + + @Test + public void testBatchingEditing_removeListenerDuringBatchEdit() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + editingState.addEditingStateListener(listener); + + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "update"); + editingState.removeEditingStateListener(listener); + editingState.endBatchEdit(); + + assertFalse(listener.called); + } + // -------- End: Test BatchEditing ------- + + @Test + public void testSetComposingRegion() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + editingState.replace(0, editingState.length(), "text"); + + // (-1, -1) clears the composing region. + editingState.setComposingRange(-1, -1); + assertEquals(-1, editingState.getComposingStart()); + assertEquals(-1, editingState.getComposingEnd()); + + editingState.setComposingRange(-1, 5); + assertEquals(-1, editingState.getComposingStart()); + assertEquals(-1, editingState.getComposingEnd()); + + editingState.setComposingRange(2, 3); + assertEquals(2, editingState.getComposingStart()); + assertEquals(3, editingState.getComposingEnd()); + + + // Empty range is invalid. Clears composing region. + editingState.setComposingRange(1, 1); + assertEquals(-1, editingState.getComposingStart()); + assertEquals(-1, editingState.getComposingEnd()); + + // Covers everything. + editingState.setComposingRange(0, editingState.length()); + assertEquals(0, editingState.getComposingStart()); + assertEquals(editingState.length(), editingState.getComposingEnd()); + } + + + // -------- Start: Test InputMethods actions ------- + @Test + public void inputMethod_testSetSelection() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final InputConnectionAdaptor inputConnection = new InputConnectionAdaptor(testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); + editingState.replace(0, editingState.length(), "initial text"); + + editingState.addEditingStateListener(listener); + + inputConnection.setSelection(0, 0); + + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertTrue(listener.selectionChanged); + assertFalse(listener.composingRegionChanged); + + listener.reset(); + + inputConnection.setSelection(5, 5); + + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertTrue(listener.selectionChanged); + assertFalse(listener.composingRegionChanged); + } + + @Test + public void inputMethod_testSetComposition() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final InputConnectionAdaptor inputConnection = new InputConnectionAdaptor(testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); + editingState.replace(0, editingState.length(), "initial text"); + + editingState.addEditingStateListener(listener); + + // setComposingRegion test. + inputConnection.setComposingRegion(1, 3); + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertFalse(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + Selection.setSelection(editingState, 0, 0); + listener.reset(); + + // setComposingText test: non-empty text, does not move cursor. + inputConnection.setComposingText("composing", -1); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertFalse(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + listener.reset(); + // setComposingText test: non-empty text, moves cursor. + inputConnection.setComposingText("composing2", 1); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertTrue(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + listener.reset(); + // setComposingText test: empty text. + inputConnection.setComposingText("", 1); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertTrue(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + // finishComposingText test. + inputConnection.setComposingText("composing text", 1); + listener.reset(); + inputConnection.finishComposingText(); + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertFalse(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + } + + + @Test + public void inputMethod_testCommitText() { + final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final InputConnectionAdaptor inputConnection = new InputConnectionAdaptor(testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); + editingState.replace(0, editingState.length(), "initial text"); + + editingState.addEditingStateListener(listener); + } + // -------- End: Test InputMethods actions ------- + + + public static class Listener implements ListenableEditingState.EditingStateWatcher { + boolean called = false; + boolean textChanged = false; + boolean selectionChanged = false; + boolean composingRegionChanged = false; + @Override + public void didChangeEditingState(boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { + called = true; + this.textChanged = textChanged; + this.selectionChanged = selectionChanged; + this.composingRegionChanged = composingRegionChanged; + } + + public void reset() { + called = false; + textChanged = false; + selectionChanged = false; + composingRegionChanged = false; + } + } +} 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 8d28843af4619..7e6c1fedbef1e 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -3,7 +3,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.AdditionalMatchers.aryEq; -import static org.mockito.AdditionalMatchers.geq; +import static org.mockito.AdditionalMatchers.gt; import static org.mockito.Matchers.anyInt; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; @@ -19,6 +19,7 @@ import android.content.Context; import android.content.res.AssetManager; import android.graphics.Insets; +import android.graphics.Rect; import android.os.Build; import android.os.Bundle; import android.provider.Settings; @@ -30,6 +31,8 @@ import android.view.ViewStructure; import android.view.WindowInsets; import android.view.WindowInsetsAnimation; +import android.view.autofill.AutofillManager; +import android.view.autofill.AutofillValue; import android.view.inputmethod.CursorAnchorInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; @@ -62,10 +65,13 @@ import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowAutofillManager; import org.robolectric.shadows.ShadowBuild; import org.robolectric.shadows.ShadowInputMethodManager; -@Config(manifest = Config.NONE, shadows = TextInputPluginTest.TestImm.class) +@Config( + manifest = Config.NONE, + shadows = {TextInputPluginTest.TestImm.class, TextInputPluginTest.TestAfm.class}) @RunWith(RobolectricTestRunner.class) public class TextInputPluginTest { @Mock FlutterJNI mockFlutterJni; @@ -574,6 +580,7 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException assertEquals(0, testImm.getLastCursorAnchorInfo().getComposingText().length()); } + // -------- Start: Autofill Tests ------- @Test public void autofill_onProvideVirtualViewStructure() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -642,11 +649,11 @@ public void autofill_onProvideVirtualViewStructure() { verify(children[0]).setAutofillId(any(), eq("1".hashCode())); verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); - verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); + verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); verify(children[1]).setAutofillId(any(), eq("2".hashCode())); verify(children[1]).setAutofillHints(aryEq(new String[] {"HINT2", "EXTRA"})); - verify(children[1]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); + verify(children[1]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); } @Test @@ -690,9 +697,193 @@ public void autofill_onProvideVirtualViewStructure_single() { verify(children[0]).setAutofillId(any(), eq("1".hashCode())); verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); // Verifies that the child has a non-zero size. - verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); + verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); } + @Test + public void autofill_testLifeCycle() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + TestAfm testAfm = + Shadow.extract(RuntimeEnvironment.application.getSystemService(AutofillManager.class)); + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + + // Set up an autofill scenario with 2 fields. + final TextInputChannel.Configuration.Autofill autofill1 = + new TextInputChannel.Configuration.Autofill( + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + final TextInputChannel.Configuration.Autofill autofill2 = + new TextInputChannel.Configuration.Autofill( + "2", + new String[] {"HINT2", "EXTRA"}, + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + + final TextInputChannel.Configuration config1 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + null); + final TextInputChannel.Configuration config2 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill2, + null); + + // Set client. This should call notifyViewExited on the FlutterView if the previous client is + // also eligible for autofill. + final TextInputChannel.Configuration autofillConfiguration = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + new TextInputChannel.Configuration[] {config1, config2}); + + textInputPlugin.setTextInputClient(0, autofillConfiguration); + + // notifyViewExited should not be called as this is the first client we set. + assertEquals(testAfm.empty, testAfm.exitId); + + // The framework updates the text, call notifyValueChanged. + textInputPlugin.setTextInputEditingState( + testView, new TextInputChannel.TextEditState("new text", -1, -1, -1, -1)); + assertEquals("new text", testAfm.changeString); + assertEquals("1".hashCode(), testAfm.changeVirtualId); + + // The input method updates the text, call notifyValueChanged. + testAfm.resetStates(); + InputConnectionAdaptor adaptor = + new InputConnectionAdaptor( + testView, + 0, + mock(TextInputChannel.class), + (ListenableEditingState) textInputPlugin.getEditable(), + new EditorInfo()); + adaptor.commitText("input from IME ", 1); + + assertEquals("input from IME new text", testAfm.changeString); + assertEquals("1".hashCode(), testAfm.changeVirtualId); + + // notifyViewExited should be called on the previous client. + testAfm.resetStates(); + textInputPlugin.setTextInputClient( + 1, + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null)); + + assertEquals("1".hashCode(), testAfm.exitId); + + // TextInputPlugin#clearTextInputClient calls notifyViewExited. + testAfm.resetStates(); + textInputPlugin.setTextInputClient(3, autofillConfiguration); + assertEquals(testAfm.empty, testAfm.exitId); + textInputPlugin.clearTextInputClient(); + assertEquals("1".hashCode(), testAfm.exitId); + + // TextInputPlugin#destroy calls notifyViewExited. + testAfm.resetStates(); + textInputPlugin.setTextInputClient(4, autofillConfiguration); + assertEquals(testAfm.empty, testAfm.exitId); + textInputPlugin.destroy(); + assertEquals("1".hashCode(), testAfm.exitId); + } + + @Test + public void autofill_testSetTextIpnutClientUpdatesSideFields() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + TestAfm testAfm = + Shadow.extract(RuntimeEnvironment.application.getSystemService(AutofillManager.class)); + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + + // Set up an autofill scenario with 2 fields. + final TextInputChannel.Configuration.Autofill autofill1 = + new TextInputChannel.Configuration.Autofill( + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + final TextInputChannel.Configuration.Autofill autofill2 = + new TextInputChannel.Configuration.Autofill( + "2", + new String[] {"HINT2", "EXTRA"}, + new TextInputChannel.TextEditState( + "Unfocused fields need love like everything does", 0, 0, -1, -1)); + + final TextInputChannel.Configuration config1 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + null); + final TextInputChannel.Configuration config2 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill2, + null); + + final TextInputChannel.Configuration autofillConfiguration = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + new TextInputChannel.Configuration[] {config1, config2}); + + textInputPlugin.setTextInputClient(0, autofillConfiguration); + + // notifyValueChanged should be called for unfocused fields. + assertEquals("2".hashCode(), testAfm.changeVirtualId); + assertEquals("Unfocused fields need love like everything does", testAfm.changeString); + } + // -------- End: Autofill Tests ------- + @Test public void respondsToInputChannelMessages() { ArgumentCaptor binaryMessageHandlerCaptor = @@ -953,4 +1144,46 @@ public CursorAnchorInfo getLastCursorAnchorInfo() { return cursorAnchorInfo; } } + + @Implements(AutofillManager.class) + public static class TestAfm extends ShadowAutofillManager { + public static int empty = -999; + + String finishState; + int changeVirtualId = empty; + String changeString; + + int enterId = empty; + int exitId = empty; + + @Implementation + public void cancel() { + finishState = "cancel"; + } + + public void commit() { + finishState = "commit"; + } + + public void notifyViewEntered(View view, int virtualId, Rect absBounds) { + enterId = virtualId; + } + + public void notifyViewExited(View view, int virtualId) { + exitId = virtualId; + } + + public void notifyValueChanged(View view, int virtualId, AutofillValue value) { + changeVirtualId = virtualId; + changeString = value.getTextValue().toString(); + } + + public void resetStates() { + finishState = null; + changeVirtualId = empty; + changeString = null; + enterId = empty; + exitId = empty; + } + } } From e7085d55f9e577635c5468fb8b4e1e8f58d40a74 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 30 Oct 2020 01:24:01 -0700 Subject: [PATCH 09/18] format --- .../plugin/editing/TextInputPlugin.java | 2 +- .../editing/ListenableEditingStateTest.java | 570 +++++++++--------- 2 files changed, 292 insertions(+), 280 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 9e02598389be3..9b404d727e37f 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -395,7 +395,7 @@ private void notifyViewExited() { afm.notifyViewExited(mView, triggerIdentifier.hashCode()); } - public void notifyValueChanged(String newValue) { + private void notifyValueChanged(String newValue) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { return; } diff --git a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java index b6101684dff76..747bd3ec5dd07 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java @@ -1,299 +1,311 @@ package io.flutter.plugin.editing; -import android.text.Editable; -import android.text.Selection; -import android.view.View; -import android.view.inputmethod.BaseInputConnection; -import android.view.inputmethod.EditorInfo; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import android.text.Editable; +import android.text.Selection; +import android.view.View; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import io.flutter.embedding.engine.systemchannels.TextInputChannel; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; -import io.flutter.embedding.engine.systemchannels.TextInputChannel; - @Config(manifest = Config.NONE) @RunWith(RobolectricTestRunner.class) public class ListenableEditingStateTest { - private BaseInputConnection getTestInputConnection(View view, Editable mEditable) { - new View(RuntimeEnvironment.application); - return new BaseInputConnection(view, true) { - @Override - public Editable getEditable() { return mEditable; } - }; - } - - // -------- Start: Test BatchEditing ------- - @Test - public void testBatchEditing() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - final View testView = new View(RuntimeEnvironment.application); - final BaseInputConnection inputConnection = getTestInputConnection(testView, editingState); - - editingState.addEditingStateListener(listener); - - editingState.replace(0, editingState.length(), "update"); - assertTrue(listener.called); - assertTrue(listener.textChanged); - assertFalse(listener.selectionChanged); - assertFalse(listener.composingRegionChanged); - - assertEquals(-1, editingState.getSelecionStart()); - assertEquals(-1, editingState.getSelecionEnd()); - - listener.reset(); - - // Batch edit depth = 1. - editingState.beginBatchEdit(); - editingState.replace(0, editingState.length(), "update1"); - assertFalse(listener.called); - // Batch edit depth = 2. - editingState.beginBatchEdit(); - editingState.replace(0, editingState.length(), "update2"); - inputConnection.setComposingRegion(0, editingState.length()); - assertFalse(listener.called); - // Batch edit depth = 1. - editingState.endBatchEdit(); - assertFalse(listener.called); - - // Batch edit depth = 2. - editingState.beginBatchEdit(); - assertFalse(listener.called); - inputConnection.setSelection(0, 0); - assertFalse(listener.called); - // Batch edit depth = 1. - editingState.endBatchEdit(); - assertFalse(listener.called); - - // Remove composing region. - inputConnection.finishComposingText(); - - // Batch edit depth = 0. Last endBatchEdit. - editingState.endBatchEdit(); - - // Now notify the listener. - assertTrue(listener.called); - assertTrue(listener.textChanged); - assertFalse(listener.composingRegionChanged); + private BaseInputConnection getTestInputConnection(View view, Editable mEditable) { + new View(RuntimeEnvironment.application); + return new BaseInputConnection(view, true) { + @Override + public Editable getEditable() { + return mEditable; + } + }; + } + + // -------- Start: Test BatchEditing ------- + @Test + public void testBatchEditing() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final BaseInputConnection inputConnection = getTestInputConnection(testView, editingState); + + editingState.addEditingStateListener(listener); + + editingState.replace(0, editingState.length(), "update"); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertFalse(listener.selectionChanged); + assertFalse(listener.composingRegionChanged); + + assertEquals(-1, editingState.getSelecionStart()); + assertEquals(-1, editingState.getSelecionEnd()); + + listener.reset(); + + // Batch edit depth = 1. + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "update1"); + assertFalse(listener.called); + // Batch edit depth = 2. + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "update2"); + inputConnection.setComposingRegion(0, editingState.length()); + assertFalse(listener.called); + // Batch edit depth = 1. + editingState.endBatchEdit(); + assertFalse(listener.called); + + // Batch edit depth = 2. + editingState.beginBatchEdit(); + assertFalse(listener.called); + inputConnection.setSelection(0, 0); + assertFalse(listener.called); + // Batch edit depth = 1. + editingState.endBatchEdit(); + assertFalse(listener.called); + + // Remove composing region. + inputConnection.finishComposingText(); + + // Batch edit depth = 0. Last endBatchEdit. + editingState.endBatchEdit(); + + // Now notify the listener. + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertFalse(listener.composingRegionChanged); + } + + @Test + public void testBatchingEditing_callEndBeforeBegin() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + editingState.addEditingStateListener(listener); + + editingState.endBatchEdit(); + assertFalse(listener.called); + + editingState.replace(0, editingState.length(), "text"); + assertTrue(listener.called); + assertTrue(listener.textChanged); + + listener.reset(); + // Does not disrupt the followup events. + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "more text"); + assertFalse(listener.called); + editingState.endBatchEdit(); + assertTrue(listener.called); + } + + @Test + public void testBatchingEditing_addListenerDuringBatchEdit() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + + editingState.beginBatchEdit(); + editingState.addEditingStateListener(listener); + editingState.replace(0, editingState.length(), "update"); + editingState.endBatchEdit(); + editingState.removeEditingStateListener(listener); + + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertTrue(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + listener.reset(); + + // Now remove before endBatchEdit(); + editingState.beginBatchEdit(); + editingState.addEditingStateListener(listener); + editingState.replace(0, editingState.length(), "update"); + editingState.removeEditingStateListener(listener); + editingState.endBatchEdit(); + + assertFalse(listener.called); + } + + @Test + public void testBatchingEditing_removeListenerDuringBatchEdit() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + editingState.addEditingStateListener(listener); + + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "update"); + editingState.removeEditingStateListener(listener); + editingState.endBatchEdit(); + + assertFalse(listener.called); + } + // -------- End: Test BatchEditing ------- + + @Test + public void testSetComposingRegion() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + editingState.replace(0, editingState.length(), "text"); + + // (-1, -1) clears the composing region. + editingState.setComposingRange(-1, -1); + assertEquals(-1, editingState.getComposingStart()); + assertEquals(-1, editingState.getComposingEnd()); + + editingState.setComposingRange(-1, 5); + assertEquals(-1, editingState.getComposingStart()); + assertEquals(-1, editingState.getComposingEnd()); + + editingState.setComposingRange(2, 3); + assertEquals(2, editingState.getComposingStart()); + assertEquals(3, editingState.getComposingEnd()); + + // Empty range is invalid. Clears composing region. + editingState.setComposingRange(1, 1); + assertEquals(-1, editingState.getComposingStart()); + assertEquals(-1, editingState.getComposingEnd()); + + // Covers everything. + editingState.setComposingRange(0, editingState.length()); + assertEquals(0, editingState.getComposingStart()); + assertEquals(editingState.length(), editingState.getComposingEnd()); + } + + // -------- Start: Test InputMethods actions ------- + @Test + public void inputMethod_testSetSelection() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final InputConnectionAdaptor inputConnection = + new InputConnectionAdaptor( + testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); + editingState.replace(0, editingState.length(), "initial text"); + + editingState.addEditingStateListener(listener); + + inputConnection.setSelection(0, 0); + + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertTrue(listener.selectionChanged); + assertFalse(listener.composingRegionChanged); + + listener.reset(); + + inputConnection.setSelection(5, 5); + + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertTrue(listener.selectionChanged); + assertFalse(listener.composingRegionChanged); + } + + @Test + public void inputMethod_testSetComposition() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final InputConnectionAdaptor inputConnection = + new InputConnectionAdaptor( + testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); + editingState.replace(0, editingState.length(), "initial text"); + + editingState.addEditingStateListener(listener); + + // setComposingRegion test. + inputConnection.setComposingRegion(1, 3); + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertFalse(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + Selection.setSelection(editingState, 0, 0); + listener.reset(); + + // setComposingText test: non-empty text, does not move cursor. + inputConnection.setComposingText("composing", -1); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertFalse(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + listener.reset(); + // setComposingText test: non-empty text, moves cursor. + inputConnection.setComposingText("composing2", 1); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertTrue(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + listener.reset(); + // setComposingText test: empty text. + inputConnection.setComposingText("", 1); + assertTrue(listener.called); + assertTrue(listener.textChanged); + assertTrue(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + + // finishComposingText test. + inputConnection.setComposingText("composing text", 1); + listener.reset(); + inputConnection.finishComposingText(); + assertTrue(listener.called); + assertFalse(listener.textChanged); + assertFalse(listener.selectionChanged); + assertTrue(listener.composingRegionChanged); + } + + @Test + public void inputMethod_testCommitText() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final InputConnectionAdaptor inputConnection = + new InputConnectionAdaptor( + testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); + editingState.replace(0, editingState.length(), "initial text"); + + editingState.addEditingStateListener(listener); + } + // -------- End: Test InputMethods actions ------- + + public static class Listener implements ListenableEditingState.EditingStateWatcher { + boolean called = false; + boolean textChanged = false; + boolean selectionChanged = false; + boolean composingRegionChanged = false; + + @Override + public void didChangeEditingState( + boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { + called = true; + this.textChanged = textChanged; + this.selectionChanged = selectionChanged; + this.composingRegionChanged = composingRegionChanged; } - @Test - public void testBatchingEditing_callEndBeforeBegin() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - editingState.addEditingStateListener(listener); - - editingState.endBatchEdit(); - assertFalse(listener.called); - - editingState.replace(0, editingState.length(), "text"); - assertTrue(listener.called); - assertTrue(listener.textChanged); - - listener.reset(); - // Does not disrupt the followup events. - editingState.beginBatchEdit(); - editingState.replace(0, editingState.length(), "more text"); - assertFalse(listener.called); - editingState.endBatchEdit(); - assertTrue(listener.called); - } - - @Test - public void testBatchingEditing_addListenerDuringBatchEdit() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - - editingState.beginBatchEdit(); - editingState.addEditingStateListener(listener); - editingState.replace(0, editingState.length(), "update"); - editingState.endBatchEdit(); - editingState.removeEditingStateListener(listener); - - assertTrue(listener.called); - assertTrue(listener.textChanged); - assertTrue(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - listener.reset(); - - // Now remove before endBatchEdit(); - editingState.beginBatchEdit(); - editingState.addEditingStateListener(listener); - editingState.replace(0, editingState.length(), "update"); - editingState.removeEditingStateListener(listener); - editingState.endBatchEdit(); - - assertFalse(listener.called); - } - - @Test - public void testBatchingEditing_removeListenerDuringBatchEdit() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - editingState.addEditingStateListener(listener); - - editingState.beginBatchEdit(); - editingState.replace(0, editingState.length(), "update"); - editingState.removeEditingStateListener(listener); - editingState.endBatchEdit(); - - assertFalse(listener.called); - } - // -------- End: Test BatchEditing ------- - - @Test - public void testSetComposingRegion() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - editingState.replace(0, editingState.length(), "text"); - - // (-1, -1) clears the composing region. - editingState.setComposingRange(-1, -1); - assertEquals(-1, editingState.getComposingStart()); - assertEquals(-1, editingState.getComposingEnd()); - - editingState.setComposingRange(-1, 5); - assertEquals(-1, editingState.getComposingStart()); - assertEquals(-1, editingState.getComposingEnd()); - - editingState.setComposingRange(2, 3); - assertEquals(2, editingState.getComposingStart()); - assertEquals(3, editingState.getComposingEnd()); - - - // Empty range is invalid. Clears composing region. - editingState.setComposingRange(1, 1); - assertEquals(-1, editingState.getComposingStart()); - assertEquals(-1, editingState.getComposingEnd()); - - // Covers everything. - editingState.setComposingRange(0, editingState.length()); - assertEquals(0, editingState.getComposingStart()); - assertEquals(editingState.length(), editingState.getComposingEnd()); - } - - - // -------- Start: Test InputMethods actions ------- - @Test - public void inputMethod_testSetSelection() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - final View testView = new View(RuntimeEnvironment.application); - final InputConnectionAdaptor inputConnection = new InputConnectionAdaptor(testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); - editingState.replace(0, editingState.length(), "initial text"); - - editingState.addEditingStateListener(listener); - - inputConnection.setSelection(0, 0); - - assertTrue(listener.called); - assertFalse(listener.textChanged); - assertTrue(listener.selectionChanged); - assertFalse(listener.composingRegionChanged); - - listener.reset(); - - inputConnection.setSelection(5, 5); - - assertTrue(listener.called); - assertFalse(listener.textChanged); - assertTrue(listener.selectionChanged); - assertFalse(listener.composingRegionChanged); - } - - @Test - public void inputMethod_testSetComposition() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - final View testView = new View(RuntimeEnvironment.application); - final InputConnectionAdaptor inputConnection = new InputConnectionAdaptor(testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); - editingState.replace(0, editingState.length(), "initial text"); - - editingState.addEditingStateListener(listener); - - // setComposingRegion test. - inputConnection.setComposingRegion(1, 3); - assertTrue(listener.called); - assertFalse(listener.textChanged); - assertFalse(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - Selection.setSelection(editingState, 0, 0); - listener.reset(); - - // setComposingText test: non-empty text, does not move cursor. - inputConnection.setComposingText("composing", -1); - assertTrue(listener.called); - assertTrue(listener.textChanged); - assertFalse(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - listener.reset(); - // setComposingText test: non-empty text, moves cursor. - inputConnection.setComposingText("composing2", 1); - assertTrue(listener.called); - assertTrue(listener.textChanged); - assertTrue(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - listener.reset(); - // setComposingText test: empty text. - inputConnection.setComposingText("", 1); - assertTrue(listener.called); - assertTrue(listener.textChanged); - assertTrue(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - // finishComposingText test. - inputConnection.setComposingText("composing text", 1); - listener.reset(); - inputConnection.finishComposingText(); - assertTrue(listener.called); - assertFalse(listener.textChanged); - assertFalse(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - } - - - @Test - public void inputMethod_testCommitText() { - final ListenableEditingState editingState = new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - final View testView = new View(RuntimeEnvironment.application); - final InputConnectionAdaptor inputConnection = new InputConnectionAdaptor(testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); - editingState.replace(0, editingState.length(), "initial text"); - - editingState.addEditingStateListener(listener); - } - // -------- End: Test InputMethods actions ------- - - - public static class Listener implements ListenableEditingState.EditingStateWatcher { - boolean called = false; - boolean textChanged = false; - boolean selectionChanged = false; - boolean composingRegionChanged = false; - @Override - public void didChangeEditingState(boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { - called = true; - this.textChanged = textChanged; - this.selectionChanged = selectionChanged; - this.composingRegionChanged = composingRegionChanged; - } - - public void reset() { - called = false; - textChanged = false; - selectionChanged = false; - composingRegionChanged = false; - } + public void reset() { + called = false; + textChanged = false; + selectionChanged = false; + composingRegionChanged = false; } + } } From 3a0ba3bbd663ac0901bde924d909e27bf0f28bb0 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 30 Oct 2020 13:12:45 -0700 Subject: [PATCH 10/18] code comments --- .../editing/ListenableEditingState.java | 23 ++++- .../plugin/editing/TextInputPlugin.java | 88 ++++++++++++------- .../editing/InputConnectionAdaptorTest.java | 8 ++ .../plugin/editing/TextInputPluginTest.java | 3 + 4 files changed, 86 insertions(+), 36 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index cbeb5a46acf1a..001e10c2e9c3b 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -10,6 +10,15 @@ import java.util.ArrayList; /// The current editing state (text, selection range, composing range) the text input plugin holds. +/// +/// This class also notifies its listeners when the editing state (text, selection, composing +// region) +/// changes. Change notifications will be deferred to the end of batch edits if there's one is in +/// progress. Listeners added during a batch end will be notified when batch edits end, even if +/// there's no real change present. +// +// Currently this class does not notify its listeners when its spans change (e.g., +// Selection.setSelection). Wrap them in a batch edit to trigger a change notification. class ListenableEditingState extends SpannableStringBuilder { interface EditingStateWatcher { void didChangeEditingState( @@ -48,6 +57,10 @@ public Editable getEditable() { }; } + /// Starts a new batch edit during which change notifications will be put on hold until all batch + /// edits end. + /// + /// Batch ends nest. public void beginBatchEdit() { if (mBatchEditNestDepth == 0 && !mListeners.isEmpty()) { mTextWhenBeginBatchEdit = toString(); @@ -59,6 +72,8 @@ public void beginBatchEdit() { mBatchEditNestDepth++; } + /// Ends the current batch edit and flush pending change notifications if the current batch edit + /// is not nested (i.e. it was the last ongoing batch edit). public void endBatchEdit() { mBatchEditNestDepth--; if (mBatchEditNestDepth != 0) { @@ -92,6 +107,9 @@ public void endBatchEdit() { mPendingListeners.clear(); } + /// Update the composing region of the current editing state. + /// + /// If the range is invalid or empty, the current composing region will be removed. public void setComposingRange(int composingStart, int composingEnd) { if (composingStart < 0 || composingStart >= composingEnd) { BaseInputConnection.removeComposingSpans(this); @@ -100,6 +118,9 @@ public void setComposingRange(int composingStart, int composingEnd) { } } + /// Called when the framework sends updates to the text input plugin. + /// + /// This method will also update the composing region if it has changed. public void setEditingState(TextInputChannel.TextEditState newState) { beginBatchEdit(); replace(0, length(), new SpannableStringBuilder(newState.text)); @@ -153,8 +174,6 @@ public SpannableStringBuilder replace( return editable; } - Log.i("flutter", editable.toString()); - Log.i("flutter", String.valueOf(getSelecionStart()) + " , " + String.valueOf(getSelecionEnd())); final boolean selectionChanged = getSelecionStart() != selectionStart || getSelecionEnd() != selectionEnd; final boolean composingRegionChanged = diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 9b404d727e37f..4aa6def2d80a0 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -370,40 +370,6 @@ private void hideTextInput(View view) { mImm.hideSoftInputFromWindow(view.getApplicationWindowToken(), 0); } - private void notifyViewEntered() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { - return; - } - - final String triggerIdentifier = configuration.autofill.uniqueIdentifier; - final int[] offset = new int[2]; - mView.getLocationOnScreen(offset); - Rect rect = new Rect(lastClientRect); - rect.offset(offset[0], offset[1]); - afm.notifyViewEntered(mView, triggerIdentifier.hashCode(), rect); - } - - private void notifyViewExited() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O - || afm == null - || configuration == null - || configuration.autofill == null) { - return; - } - - final String triggerIdentifier = configuration.autofill.uniqueIdentifier; - afm.notifyViewExited(mView, triggerIdentifier.hashCode()); - } - - private void notifyValueChanged(String newValue) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { - return; - } - - final String triggerIdentifier = configuration.autofill.uniqueIdentifier; - afm.notifyValueChanged(mView, triggerIdentifier.hashCode(), AutofillValue.forText(newValue)); - } - @VisibleForTesting void setTextInputClient(int client, TextInputChannel.Configuration configuration) { // Call notifyViewExited on the previous field. @@ -441,6 +407,7 @@ private void setPlatformViewTextInputClient(int platformViewId) { @VisibleForTesting void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { mLastKnownFrameworkTextEditingState = state; + // mEditable.setEditingState(state); // Restart if there is a pending restart or the device requires a force restart @@ -618,10 +585,63 @@ public void didChangeEditingState( // -------- End: ListenableEditingState watcher implementation ------- // -------- Start: Autofill ------- + // ### Setup and provide the initial text values and hints. + // + // The TextInputConfiguration used to setup the current client is also used for populating + // "AutofillVirtualStructure" when requested by the autofill manager (AFM), See + // #onProvideAutofillVirtualStructure. + // + // ### Keep the AFM updated + // + // The autofill session connected to The AFM keeps a copy of the current state for each reported + // field in "AutofillVirtualStructure"(instead of holding a reference to those fields), so the AFM + // needs to be notified when text changes if the client was part of the "AutofillVirtualStructure" + // previously reported to the AFM. This step is essential for triggering autofill save. This is + // done in #didChangeEditingState by calling #notifyValueChanged. + // + // Additionally when the text input plugin receives a new TextInputConfiguration, + // AutofillManager#notifyValueChanged will be called on all the autofillable fields contained in + // the TextInputConfiguration, in case some of them are tracked by the session and their values + // have changed. However if the value of an unfocused EditableText is changed in the framework, + // such change will not be sent to the text input plugin until the next TextInput.attach call. private boolean needsAutofill() { return mAutofillConfigurations != null; } + private void notifyViewEntered() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { + return; + } + + final String triggerIdentifier = configuration.autofill.uniqueIdentifier; + final int[] offset = new int[2]; + mView.getLocationOnScreen(offset); + Rect rect = new Rect(lastClientRect); + rect.offset(offset[0], offset[1]); + afm.notifyViewEntered(mView, triggerIdentifier.hashCode(), rect); + } + + private void notifyViewExited() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O + || afm == null + || configuration == null + || configuration.autofill == null) { + return; + } + + final String triggerIdentifier = configuration.autofill.uniqueIdentifier; + afm.notifyViewExited(mView, triggerIdentifier.hashCode()); + } + + private void notifyValueChanged(String newValue) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { + return; + } + + final String triggerIdentifier = configuration.autofill.uniqueIdentifier; + afm.notifyValueChanged(mView, triggerIdentifier.hashCode(), AutofillValue.forText(newValue)); + } + private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration configuration) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index 0b5f7043328a5..71fd2f05c1995 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -17,6 +17,7 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.res.AssetManager; +import android.os.Build; import android.os.Bundle; import android.text.Emoji; import android.text.InputType; @@ -914,6 +915,9 @@ public void testMethod_getExtractedText() { @Test public void testExtractedText_monitoring() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } ListenableEditingState editable = sampleEditable(5, 5); View testView = new View(RuntimeEnvironment.application); InputConnectionAdaptor adaptor = @@ -962,6 +966,10 @@ public void testExtractedText_monitoring() { @Test public void testCursorAnchorInfo() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + ListenableEditingState editable = sampleEditable(5, 5); View testView = new View(RuntimeEnvironment.application); InputConnectionAdaptor adaptor = 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 7e6c1fedbef1e..6bac652a8e5ca 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -1174,6 +1174,9 @@ public void notifyViewExited(View view, int virtualId) { } public void notifyValueChanged(View view, int virtualId, AutofillValue value) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } changeVirtualId = virtualId; changeString = value.getTextValue().toString(); } From b392823ec91f548110e37c0de4a12226a351558d Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 30 Oct 2020 14:36:56 -0700 Subject: [PATCH 11/18] more comments --- .../editing/InputConnectionAdaptor.java | 5 ++ .../editing/ListenableEditingState.java | 47 +++++----- .../plugin/editing/TextInputPlugin.java | 1 - .../editing/ListenableEditingStateTest.java | 86 +++++++++++++------ 4 files changed, 91 insertions(+), 48 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index ba4da774fa3ce..b52b066687944 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -484,6 +484,11 @@ public boolean performEditorAction(int actionCode) { @Override public void didChangeEditingState( boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { + // Notifies the input method that the editing state has changed. The input method may decide + // to modify/reject these changes and ends up calling TextInputChannel#updateEditingState to + // send the update to the framework. This could result in an infinite loop if the framework + // also rejects/modifies the new update. + // Always send selection update. InputMethodManager#updateSelection skips sending the message // if none of the parameters have changed since the last time we called it. mImm.updateSelection( diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index 001e10c2e9c3b..26aa7bca9474d 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -41,6 +41,8 @@ void didChangeEditingState( private BaseInputConnection mDummyConnection; + // The View is only use for creating a dummy BaseInputConnection which is used in + // setComposingRegion. The View needs to have a non-null Context. public ListenableEditingState(TextInputChannel.TextEditState configuration, View view) { super(); if (configuration != null) { @@ -62,49 +64,50 @@ public Editable getEditable() { /// /// Batch ends nest. public void beginBatchEdit() { - if (mBatchEditNestDepth == 0 && !mListeners.isEmpty()) { + mBatchEditNestDepth++; + if (mBatchEditNestDepth == 1 && !mListeners.isEmpty()) { mTextWhenBeginBatchEdit = toString(); mSelectionStartWhenBeginBatchEdit = getSelecionStart(); mSelectionEndWhenBeginBatchEdit = getSelecionEnd(); mComposingStartWhenBeginBatchEdit = getComposingStart(); mComposingEndWhenBeginBatchEdit = getComposingEnd(); } - mBatchEditNestDepth++; } /// Ends the current batch edit and flush pending change notifications if the current batch edit /// is not nested (i.e. it was the last ongoing batch edit). public void endBatchEdit() { - mBatchEditNestDepth--; - if (mBatchEditNestDepth != 0) { - mBatchEditNestDepth = mBatchEditNestDepth < 0 ? 0 : mBatchEditNestDepth; + if (mBatchEditNestDepth == 0) { return; } - for (final EditingStateWatcher watcher : mPendingListeners) { - watcher.didChangeEditingState(true, true, true); - } + if (mBatchEditNestDepth == 1) { + for (final EditingStateWatcher watcher : mPendingListeners) { + watcher.didChangeEditingState(true, true, true); + } - if (!mListeners.isEmpty()) { - Log.v(TAG, "didFinishBatchEdit with " + String.valueOf(mListeners.size()) + " listener(s)"); - final boolean textChanged = !toString().equals(mTextWhenBeginBatchEdit); - final boolean selectionChanged = - mSelectionStartWhenBeginBatchEdit != getSelecionStart() - || mSelectionEndWhenBeginBatchEdit != getSelecionEnd(); - final boolean composingRegionChanged = - mComposingStartWhenBeginBatchEdit != getComposingStart() - || mComposingEndWhenBeginBatchEdit != getComposingEnd(); - if (textChanged || selectionChanged || composingRegionChanged) { - for (int i = 0; i < mListeners.size(); i++) { - mListeners - .get(i) - .didChangeEditingState(textChanged, selectionChanged, composingRegionChanged); + if (!mListeners.isEmpty()) { + Log.v(TAG, "didFinishBatchEdit with " + String.valueOf(mListeners.size()) + " listener(s)"); + final boolean textChanged = !toString().equals(mTextWhenBeginBatchEdit); + final boolean selectionChanged = + mSelectionStartWhenBeginBatchEdit != getSelecionStart() + || mSelectionEndWhenBeginBatchEdit != getSelecionEnd(); + final boolean composingRegionChanged = + mComposingStartWhenBeginBatchEdit != getComposingStart() + || mComposingEndWhenBeginBatchEdit != getComposingEnd(); + if (textChanged || selectionChanged || composingRegionChanged) { + for (int i = 0; i < mListeners.size(); i++) { + mListeners + .get(i) + .didChangeEditingState(textChanged, selectionChanged, composingRegionChanged); + } } } } mListeners.addAll(mPendingListeners); mPendingListeners.clear(); + mBatchEditNestDepth--; } /// Update the composing region of the current editing state. diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 4aa6def2d80a0..2ac8580997315 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -407,7 +407,6 @@ private void setPlatformViewTextInputClient(int platformViewId) { @VisibleForTesting void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { mLastKnownFrameworkTextEditingState = state; - // mEditable.setEditingState(state); // Restart if there is a pending restart or the device requires a force restart diff --git a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java index 747bd3ec5dd07..5fbb736d59a6d 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java @@ -42,7 +42,7 @@ public void testBatchEditing() { editingState.addEditingStateListener(listener); editingState.replace(0, editingState.length(), "update"); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertTrue(listener.textChanged); assertFalse(listener.selectionChanged); assertFalse(listener.composingRegionChanged); @@ -55,24 +55,24 @@ public void testBatchEditing() { // Batch edit depth = 1. editingState.beginBatchEdit(); editingState.replace(0, editingState.length(), "update1"); - assertFalse(listener.called); + assertFalse(listener.isCalled()); // Batch edit depth = 2. editingState.beginBatchEdit(); editingState.replace(0, editingState.length(), "update2"); inputConnection.setComposingRegion(0, editingState.length()); - assertFalse(listener.called); + assertFalse(listener.isCalled()); // Batch edit depth = 1. editingState.endBatchEdit(); - assertFalse(listener.called); + assertFalse(listener.isCalled()); // Batch edit depth = 2. editingState.beginBatchEdit(); - assertFalse(listener.called); + assertFalse(listener.isCalled()); inputConnection.setSelection(0, 0); - assertFalse(listener.called); + assertFalse(listener.isCalled()); // Batch edit depth = 1. editingState.endBatchEdit(); - assertFalse(listener.called); + assertFalse(listener.isCalled()); // Remove composing region. inputConnection.finishComposingText(); @@ -81,7 +81,7 @@ public void testBatchEditing() { editingState.endBatchEdit(); // Now notify the listener. - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertTrue(listener.textChanged); assertFalse(listener.composingRegionChanged); } @@ -94,19 +94,19 @@ public void testBatchingEditing_callEndBeforeBegin() { editingState.addEditingStateListener(listener); editingState.endBatchEdit(); - assertFalse(listener.called); + assertFalse(listener.isCalled()); editingState.replace(0, editingState.length(), "text"); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertTrue(listener.textChanged); listener.reset(); // Does not disrupt the followup events. editingState.beginBatchEdit(); editingState.replace(0, editingState.length(), "more text"); - assertFalse(listener.called); + assertFalse(listener.isCalled()); editingState.endBatchEdit(); - assertTrue(listener.called); + assertTrue(listener.isCalled()); } @Test @@ -121,13 +121,19 @@ public void testBatchingEditing_addListenerDuringBatchEdit() { editingState.endBatchEdit(); editingState.removeEditingStateListener(listener); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertTrue(listener.textChanged); assertTrue(listener.selectionChanged); assertTrue(listener.composingRegionChanged); listener.reset(); + // Verifies the listener is officially added. + editingState.replace(0, editingState.length(), "more updates"); + assertTrue(listener.isCalled()); + assertTrue(listener.textChanged); + + listener.reset(); // Now remove before endBatchEdit(); editingState.beginBatchEdit(); editingState.addEditingStateListener(listener); @@ -135,7 +141,7 @@ public void testBatchingEditing_addListenerDuringBatchEdit() { editingState.removeEditingStateListener(listener); editingState.endBatchEdit(); - assertFalse(listener.called); + assertFalse(listener.isCalled()); } @Test @@ -150,7 +156,33 @@ public void testBatchingEditing_removeListenerDuringBatchEdit() { editingState.removeEditingStateListener(listener); editingState.endBatchEdit(); - assertFalse(listener.called); + assertFalse(listener.isCalled()); + } + + @Test + public void testBatchingEditing_listenerCallsReplaceWhenBatchEditEnds() { + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + + final Listener listener = + new Listener() { + @Override + public void didChangeEditingState( + boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { + super.didChangeEditingState(textChanged, selectionChanged, composingRegionChanged); + editingState.replace( + 0, editingState.length(), "one does not simply replace the text in the listener"); + } + }; + editingState.addEditingStateListener(listener); + + editingState.beginBatchEdit(); + editingState.replace(0, editingState.length(), "update"); + editingState.endBatchEdit(); + + assertTrue(listener.isCalled()); + assertEquals(1, listener.timesCalled); + assertEquals("one does not simply replace the text in the listener", editingState.toString()); } // -------- End: Test BatchEditing ------- @@ -200,7 +232,7 @@ public void inputMethod_testSetSelection() { inputConnection.setSelection(0, 0); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertFalse(listener.textChanged); assertTrue(listener.selectionChanged); assertFalse(listener.composingRegionChanged); @@ -209,7 +241,7 @@ public void inputMethod_testSetSelection() { inputConnection.setSelection(5, 5); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertFalse(listener.textChanged); assertTrue(listener.selectionChanged); assertFalse(listener.composingRegionChanged); @@ -230,7 +262,7 @@ public void inputMethod_testSetComposition() { // setComposingRegion test. inputConnection.setComposingRegion(1, 3); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertFalse(listener.textChanged); assertFalse(listener.selectionChanged); assertTrue(listener.composingRegionChanged); @@ -240,7 +272,7 @@ public void inputMethod_testSetComposition() { // setComposingText test: non-empty text, does not move cursor. inputConnection.setComposingText("composing", -1); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertTrue(listener.textChanged); assertFalse(listener.selectionChanged); assertTrue(listener.composingRegionChanged); @@ -248,7 +280,7 @@ public void inputMethod_testSetComposition() { listener.reset(); // setComposingText test: non-empty text, moves cursor. inputConnection.setComposingText("composing2", 1); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertTrue(listener.textChanged); assertTrue(listener.selectionChanged); assertTrue(listener.composingRegionChanged); @@ -256,7 +288,7 @@ public void inputMethod_testSetComposition() { listener.reset(); // setComposingText test: empty text. inputConnection.setComposingText("", 1); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertTrue(listener.textChanged); assertTrue(listener.selectionChanged); assertTrue(listener.composingRegionChanged); @@ -265,7 +297,7 @@ public void inputMethod_testSetComposition() { inputConnection.setComposingText("composing text", 1); listener.reset(); inputConnection.finishComposingText(); - assertTrue(listener.called); + assertTrue(listener.isCalled()); assertFalse(listener.textChanged); assertFalse(listener.selectionChanged); assertTrue(listener.composingRegionChanged); @@ -287,7 +319,11 @@ public void inputMethod_testCommitText() { // -------- End: Test InputMethods actions ------- public static class Listener implements ListenableEditingState.EditingStateWatcher { - boolean called = false; + public boolean isCalled() { + return timesCalled > 0; + } + + int timesCalled = 0; boolean textChanged = false; boolean selectionChanged = false; boolean composingRegionChanged = false; @@ -295,14 +331,14 @@ public static class Listener implements ListenableEditingState.EditingStateWatch @Override public void didChangeEditingState( boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { - called = true; + timesCalled++; this.textChanged = textChanged; this.selectionChanged = selectionChanged; this.composingRegionChanged = composingRegionChanged; } public void reset() { - called = false; + timesCalled = 0; textChanged = false; selectionChanged = false; composingRegionChanged = false; From 7af82b2f3a04a6516247fc18ddb0e70d81cc5d31 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 30 Oct 2020 15:04:21 -0700 Subject: [PATCH 12/18] more tests --- .../editing/ListenableEditingState.java | 13 +++---- .../editing/ListenableEditingStateTest.java | 38 ++++++++++++++++++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index 26aa7bca9474d..ce8912567db60 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -11,13 +11,12 @@ /// The current editing state (text, selection range, composing range) the text input plugin holds. /// -/// This class also notifies its listeners when the editing state (text, selection, composing -// region) -/// changes. Change notifications will be deferred to the end of batch edits if there's one is in -/// progress. Listeners added during a batch end will be notified when batch edits end, even if -/// there's no real change present. +/// As the name implies, this class also notifies its listeners when the editing state (text, +/// selection, composing region) changes. Change notifications will be deferred to the end of batch +/// edits if there's one in progress. Listeners added during a batch end will be notified when all +/// batch edits end (i.e. when the outmost batch edit ends), even if there's no real change. // -// Currently this class does not notify its listeners when its spans change (e.g., +// Currently this class does not notify its listeners on spans-only changes (e.g., // Selection.setSelection). Wrap them in a batch edit to trigger a change notification. class ListenableEditingState extends SpannableStringBuilder { interface EditingStateWatcher { @@ -41,7 +40,7 @@ void didChangeEditingState( private BaseInputConnection mDummyConnection; - // The View is only use for creating a dummy BaseInputConnection which is used in + // The View is only use for creating a dummy BaseInputConnection which is only used in // setComposingRegion. The View needs to have a non-null Context. public ListenableEditingState(TextInputChannel.TextEditState configuration, View view) { super(); diff --git a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java index 5fbb736d59a6d..c76cb9f99870d 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java @@ -11,6 +11,7 @@ import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import io.flutter.embedding.engine.systemchannels.TextInputChannel; +import java.util.ArrayList; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -119,7 +120,6 @@ public void testBatchingEditing_addListenerDuringBatchEdit() { editingState.addEditingStateListener(listener); editingState.replace(0, editingState.length(), "update"); editingState.endBatchEdit(); - editingState.removeEditingStateListener(listener); assertTrue(listener.isCalled()); assertTrue(listener.textChanged); @@ -132,6 +132,7 @@ public void testBatchingEditing_addListenerDuringBatchEdit() { editingState.replace(0, editingState.length(), "more updates"); assertTrue(listener.isCalled()); assertTrue(listener.textChanged); + editingState.removeEditingStateListener(listener); listener.reset(); // Now remove before endBatchEdit(); @@ -217,6 +218,41 @@ public void testSetComposingRegion() { } // -------- Start: Test InputMethods actions ------- + @Test + public void inputMethod_batchEditingBeginAndEnd() { + final ArrayList batchMarkers = new ArrayList<>(); + final ListenableEditingState editingState = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)) { + @Override + public final void beginBatchEdit() { + super.beginBatchEdit(); + batchMarkers.add("begin"); + } + + @Override + public void endBatchEdit() { + super.endBatchEdit(); + batchMarkers.add("end"); + } + }; + + final Listener listener = new Listener(); + final View testView = new View(RuntimeEnvironment.application); + final InputConnectionAdaptor inputConnection = + new InputConnectionAdaptor( + testView, 0, mock(TextInputChannel.class), editingState, new EditorInfo()); + + // Make sure begin/endBatchEdit is called on the Editable when the input method calls + // InputConnection#begin/endBatchEdit. + inputConnection.beginBatchEdit(); + assertEquals(1, batchMarkers.size()); + assertEquals("begin", batchMarkers.get(0)); + + inputConnection.endBatchEdit(); + assertEquals(2, batchMarkers.size()); + assertEquals("end", batchMarkers.get(1)); + } + @Test public void inputMethod_testSetSelection() { final ListenableEditingState editingState = From b8858a092f8fec6ac983a346a8ddb10e873d6e87 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Fri, 30 Oct 2020 16:34:40 -0700 Subject: [PATCH 13/18] do not change editing state in a listener callback --- .../editing/InputConnectionAdaptor.java | 4 -- .../editing/ListenableEditingState.java | 53 +++++++++++++------ shell/platform/android/test/README.md | 4 +- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index b52b066687944..6910e112dfa69 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -164,7 +164,6 @@ public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLeng @Override public boolean setComposingRegion(int start, int end) { - Log.v("flutter", "engine: set CR: " + String.valueOf(start) + " - " + String.valueOf(end)); final boolean result = super.setComposingRegion(start, end); return result; } @@ -172,7 +171,6 @@ public boolean setComposingRegion(int start, int end) { @Override public boolean setComposingText(CharSequence text, int newCursorPosition) { boolean result; - Log.v("flutter", "engine: set CT: " + text + ", " + String.valueOf(newCursorPosition)); beginBatchEdit(); if (text.length() == 0) { result = super.commitText(text, newCursorPosition); @@ -185,7 +183,6 @@ public boolean setComposingText(CharSequence text, int newCursorPosition) { @Override public boolean finishComposingText() { - Log.v("flutter", "engine: finish composing"); final boolean result = super.finishComposingText(); return result; } @@ -193,7 +190,6 @@ public boolean finishComposingText() { // TODO(garyq): Implement a more feature complete version of getExtractedText @Override public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { - // Input methods may use this method to get the current content of the final boolean textMonitor = (flags & GET_EXTRACTED_TEXT_MONITOR) != 0; if (textMonitor == (mExtractRequest == null)) { Log.d(TAG, "The input method toggled text monitoring " + (textMonitor ? "on" : "off")); diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index ce8912567db60..72aa6d39884f6 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -14,12 +14,16 @@ /// As the name implies, this class also notifies its listeners when the editing state (text, /// selection, composing region) changes. Change notifications will be deferred to the end of batch /// edits if there's one in progress. Listeners added during a batch end will be notified when all -/// batch edits end (i.e. when the outmost batch edit ends), even if there's no real change. +/// batch edits end (i.e. when the outermost batch edit ends), even if there's no real change. +/// +/// Changing the editing state in a didChangeEditingState callback may cause unexpected behavior. // // Currently this class does not notify its listeners on spans-only changes (e.g., // Selection.setSelection). Wrap them in a batch edit to trigger a change notification. class ListenableEditingState extends SpannableStringBuilder { interface EditingStateWatcher { + // Changing the editing state in a didChangeEditingState callback may cause unexpected + // behavior. void didChangeEditingState( boolean textChanged, boolean selectionChanged, boolean composingRegionChanged); } @@ -27,6 +31,7 @@ void didChangeEditingState( private static final String TAG = "flutter"; private int mBatchEditNestDepth = 0; + private int mChangeNotificationDepth = 0; private ArrayList mListeners = new ArrayList<>(); private ArrayList mPendingListeners = new ArrayList<>(); @@ -64,6 +69,9 @@ public Editable getEditable() { /// Batch ends nest. public void beginBatchEdit() { mBatchEditNestDepth++; + if (mChangeNotificationDepth > 0) { + Log.e(TAG, "editing state should not be changed in a listener callback"); + } if (mBatchEditNestDepth == 1 && !mListeners.isEmpty()) { mTextWhenBeginBatchEdit = toString(); mSelectionStartWhenBeginBatchEdit = getSelecionStart(); @@ -81,8 +89,8 @@ public void endBatchEdit() { } if (mBatchEditNestDepth == 1) { - for (final EditingStateWatcher watcher : mPendingListeners) { - watcher.didChangeEditingState(true, true, true); + for (final EditingStateWatcher listener : mPendingListeners) { + notifyListener(listener, true, true, true); } if (!mListeners.isEmpty()) { @@ -94,13 +102,8 @@ public void endBatchEdit() { final boolean composingRegionChanged = mComposingStartWhenBeginBatchEdit != getComposingStart() || mComposingEndWhenBeginBatchEdit != getComposingEnd(); - if (textChanged || selectionChanged || composingRegionChanged) { - for (int i = 0; i < mListeners.size(); i++) { - mListeners - .get(i) - .didChangeEditingState(textChanged, selectionChanged, composingRegionChanged); - } - } + + notifyListenersIfNeeded(textChanged, selectionChanged, composingRegionChanged); } } @@ -158,6 +161,11 @@ public void removeEditingStateListener(EditingStateWatcher listener) { @Override public SpannableStringBuilder replace( int start, int end, CharSequence tb, int tbstart, int tbend) { + + if (mChangeNotificationDepth > 0) { + Log.e(TAG, "editing state should not be changed in a listener callback"); + } + boolean textChanged = end - start != tbend - tbstart; for (int i = 0; i < end - start && !textChanged; i++) { textChanged |= charAt(start + i) != tb.charAt(tbstart + i); @@ -180,14 +188,27 @@ public SpannableStringBuilder replace( getSelecionStart() != selectionStart || getSelecionEnd() != selectionEnd; final boolean composingRegionChanged = getComposingStart() != composingStart || getComposingEnd() != composingEnd; - if (textChanged || selectionChanged || composingRegionChanged) { - for (int i = 0; i < mListeners.size(); i++) { - mListeners - .get(i) - .didChangeEditingState(textChanged, selectionChanged, composingRegionChanged); + notifyListenersIfNeeded(textChanged, selectionChanged, composingRegionChanged); + return editable; + } + + private void notifyListener( + EditingStateWatcher listener, + boolean textChanged, + boolean selectionChanged, + boolean composingChanged) { + mChangeNotificationDepth++; + listener.didChangeEditingState(textChanged, selectionChanged, composingChanged); + mChangeNotificationDepth--; + } + + private void notifyListenersIfNeeded( + boolean textChanged, boolean selectionChanged, boolean composingChanged) { + if (textChanged || selectionChanged || composingChanged) { + for (final EditingStateWatcher listener : mListeners) { + notifyListener(listener, textChanged, selectionChanged, composingChanged); } } - return editable; } public final int getSelecionStart() { diff --git a/shell/platform/android/test/README.md b/shell/platform/android/test/README.md index 2c0f17568c064..2958b4323f6f4 100644 --- a/shell/platform/android/test/README.md +++ b/shell/platform/android/test/README.md @@ -15,8 +15,8 @@ integration tests in other repos. 2. Add your file to the `sources` of the `robolectric_tests` build target in `/shell/platform/android/BUILD.gn`. This compiles the test class into the test jar. -3. Add your class to the `@SuiteClasses` annotation in `FlutterTestSuite.java` - and import the test class. This makes sure the test is actually executed at +3. Import your test class and add it to the `@SuiteClasses` annotation in + `FlutterTestSuite.java`. This makes sure the test is actually executed at run time. 4. Write your test. 5. Build and run with `testing/run_tests.py [--type=java] [--java-filter=]`. From 2ddda3329dfea1e69eae71eb218bce0fcf8a4269 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 2 Nov 2020 23:27:27 -0800 Subject: [PATCH 14/18] more warnings --- .../editing/InputConnectionAdaptor.java | 4 +++ .../editing/ListenableEditingState.java | 31 ++++++++++++------- .../plugin/editing/TextInputPlugin.java | 4 +-- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 6910e112dfa69..641365413fa2a 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -187,6 +187,10 @@ public boolean finishComposingText() { return result; } + // When there's not enough vertical screen space, the IME may enter fullscreen mode and this + // method will be used to get (a portion of) the currently edited text. Samsung keyboard seems + // to use this method instead of InputConnection#getText{Before,After}Cursor. + // See https://github.com/flutter/engine/pull/17426. // TODO(garyq): Implement a more feature complete version of getExtractedText @Override public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index 72aa6d39884f6..6400001903ece 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -11,12 +11,13 @@ /// The current editing state (text, selection range, composing range) the text input plugin holds. /// -/// As the name implies, this class also notifies its listeners when the editing state (text, -/// selection, composing region) changes. Change notifications will be deferred to the end of batch -/// edits if there's one in progress. Listeners added during a batch end will be notified when all -/// batch edits end (i.e. when the outermost batch edit ends), even if there's no real change. +/// As the name implies, this class also notifies its listeners when the editing state changes. When +/// there're ongoing batch edits, change notifications will be deferred until all batch edits end +/// (i.e. when the outermost batch edit ends). Listeners added during a batch edit will always be +/// notified when all batch edits end, even if there's no real change. /// -/// Changing the editing state in a didChangeEditingState callback may cause unexpected behavior. +/// Adding/removing listeners or changing the editing state in a didChangeEditingState callback may +/// cause unexpected behavior. // // Currently this class does not notify its listeners on spans-only changes (e.g., // Selection.setSelection). Wrap them in a batch edit to trigger a change notification. @@ -31,6 +32,8 @@ void didChangeEditingState( private static final String TAG = "flutter"; private int mBatchEditNestDepth = 0; + // We don't support adding/removing listeners, or changing the editing state in a listener + // callback for now. private int mChangeNotificationDepth = 0; private ArrayList mListeners = new ArrayList<>(); private ArrayList mPendingListeners = new ArrayList<>(); @@ -45,8 +48,8 @@ void didChangeEditingState( private BaseInputConnection mDummyConnection; - // The View is only use for creating a dummy BaseInputConnection which is only used in - // setComposingRegion. The View needs to have a non-null Context. + // The View is only use for creating a dummy BaseInputConnection for setComposingRegion. The View + // needs to have a non-null Context. public ListenableEditingState(TextInputChannel.TextEditState configuration, View view) { super(); if (configuration != null) { @@ -66,7 +69,7 @@ public Editable getEditable() { /// Starts a new batch edit during which change notifications will be put on hold until all batch /// edits end. /// - /// Batch ends nest. + /// Batch edits nest. public void beginBatchEdit() { mBatchEditNestDepth++; if (mChangeNotificationDepth > 0) { @@ -82,12 +85,12 @@ public void beginBatchEdit() { } /// Ends the current batch edit and flush pending change notifications if the current batch edit - /// is not nested (i.e. it was the last ongoing batch edit). + /// is not nested (i.e. it is the last ongoing batch edit). public void endBatchEdit() { if (mBatchEditNestDepth == 0) { + Log.e(TAG, "endBatchEdit called without a matching beginBatchEdit"); return; } - if (mBatchEditNestDepth == 1) { for (final EditingStateWatcher listener : mPendingListeners) { notifyListener(listener, true, true, true); @@ -128,7 +131,7 @@ public void setComposingRange(int composingStart, int composingEnd) { /// This method will also update the composing region if it has changed. public void setEditingState(TextInputChannel.TextEditState newState) { beginBatchEdit(); - replace(0, length(), new SpannableStringBuilder(newState.text)); + replace(0, length(), newState.text); if (newState.selectionStart >= 0 && newState.selectionEnd >= newState.selectionStart) { Selection.setSelection(this, newState.selectionStart, newState.selectionEnd); @@ -140,6 +143,9 @@ public void setEditingState(TextInputChannel.TextEditState newState) { } public void addEditingStateListener(EditingStateWatcher listener) { + if (mChangeNotificationDepth > 0) { + Log.e(TAG, "adding a listener " + listener.toString() + " in a listener callback"); + } // It is possible for a listener to get added during a batch edit. When that happens we always // notify the new listeners. // This does not check if the listener is already in the list of existing listeners. @@ -152,6 +158,9 @@ public void addEditingStateListener(EditingStateWatcher listener) { } public void removeEditingStateListener(EditingStateWatcher listener) { + if (mChangeNotificationDepth > 0) { + Log.e(TAG, "removing a listener " + listener.toString() + " in a listener callback"); + } mListeners.remove(listener); if (mBatchEditNestDepth > 0) { mPendingListeners.remove(listener); diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 2ac8580997315..cfd5baf22bca5 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -498,10 +498,10 @@ void clearTextInputClient() { // field's focus node will be notified that it lost focus after the AndroidView was notified // that it gained // focus. When this happens the text field will send a clearTextInput command which we ignore. - // By doing this we prevent the framework from clearing a platform view input client(the only + // By doing this we prevent the framework from clearing a platform view input client (the only // way to do so // is to set a new framework text client). I don't see an obvious use case for "clearing" a - // platform views + // platform view's // text input client, and it may be error prone as we don't know how the platform view manages // the input // connection and we probably shouldn't interfere. From 874c9aa0bedd1b5dc742b5241d86d7adc7c7ac0f Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 4 Nov 2020 00:52:17 -0800 Subject: [PATCH 15/18] remove redundant warning --- .../plugin/editing/InputConnectionAdaptor.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 641365413fa2a..58c6f3d27e913 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -484,13 +484,14 @@ public boolean performEditorAction(int actionCode) { @Override public void didChangeEditingState( boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { - // Notifies the input method that the editing state has changed. The input method may decide - // to modify/reject these changes and ends up calling TextInputChannel#updateEditingState to - // send the update to the framework. This could result in an infinite loop if the framework - // also rejects/modifies the new update. - - // Always send selection update. InputMethodManager#updateSelection skips sending the message - // if none of the parameters have changed since the last time we called it. + // This method notifies the input method that the editing state has changed. + // updateSelection is mandatory. updateExtractedText and updateCursorAnchorInfo + // are on demand (if the input method set the correspoinding monitoring + // flags). See getExtractedText and requestCursorUpdates. + + // Always send selection update. InputMethodManager#updateSelection skips + // sending the message if none of the parameters have changed since the last + // time we called it. mImm.updateSelection( mFlutterView, mEditable.getSelecionStart(), From 3cf59fbe9f50de58ee14abb8e3efd3590b8855f7 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Sat, 7 Nov 2020 22:49:53 -0800 Subject: [PATCH 16/18] add missing license --- ci/licenses_golden/licenses_flutter | 1 + .../io/flutter/plugin/editing/ListenableEditingState.java | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 93948761cd3b5..8a8bfefc08d67 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -809,6 +809,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/common/StringCod FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java 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/TextInputPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/localization/LocalizationPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/mouse/MouseCursorPlugin.java diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index 6400001903ece..9f3a84b1a1839 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -1,3 +1,7 @@ +// 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.text.Editable; From 5aa1561cc0d7f75d37a317e40bd6e31e08652349 Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 10 Nov 2020 16:11:43 -0800 Subject: [PATCH 17/18] review --- .../editing/InputConnectionAdaptor.java | 14 ++++++------ .../editing/ListenableEditingState.java | 22 +++++++++---------- .../plugin/editing/TextInputPlugin.java | 12 +++++----- .../editing/ListenableEditingStateTest.java | 4 ++-- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 2716a7241211b..7615126b135e2 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -33,7 +33,7 @@ class InputConnectionAdaptor extends BaseInputConnection implements ListenableEditingState.EditingStateWatcher { - private static final String TAG = "flutter"; + private static final String TAG = "InputConnectionAdaptor"; private final View mFlutterView; private final int mClient; @@ -95,8 +95,8 @@ private ExtractedText getExtractedText(ExtractedTextRequest request) { mExtractedText.startOffset = 0; mExtractedText.partialStartOffset = -1; mExtractedText.partialEndOffset = -1; - mExtractedText.selectionStart = mEditable.getSelecionStart(); - mExtractedText.selectionEnd = mEditable.getSelecionEnd(); + mExtractedText.selectionStart = mEditable.getSelectionStart(); + mExtractedText.selectionEnd = mEditable.getSelectionEnd(); mExtractedText.text = request == null || (request.flags & GET_TEXT_WITH_STYLES) == 0 ? mEditable.toString() @@ -115,7 +115,7 @@ private CursorAnchorInfo getCursorAnchorInfo() { } mCursorAnchorInfoBuilder.setSelectionRange( - mEditable.getSelecionStart(), mEditable.getSelecionEnd()); + mEditable.getSelectionStart(), mEditable.getSelectionEnd()); final int composingStart = mEditable.getComposingStart(); final int composingEnd = mEditable.getComposingEnd(); if (composingStart >= 0 && composingEnd > composingStart) { @@ -153,7 +153,7 @@ public boolean commitText(CharSequence text, int newCursorPosition) { @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - if (mEditable.getSelecionStart() == -1) { + if (mEditable.getSelectionStart() == -1) { return true; } @@ -507,8 +507,8 @@ public void didChangeEditingState( // time we called it. mImm.updateSelection( mFlutterView, - mEditable.getSelecionStart(), - mEditable.getSelecionEnd(), + mEditable.getSelectionStart(), + mEditable.getSelectionEnd(), mEditable.getComposingStart(), mEditable.getComposingEnd()); diff --git a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java index 9f3a84b1a1839..49f09bb996900 100644 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -33,7 +33,7 @@ void didChangeEditingState( boolean textChanged, boolean selectionChanged, boolean composingRegionChanged); } - private static final String TAG = "flutter"; + private static final String TAG = "ListenableEditingState"; private int mBatchEditNestDepth = 0; // We don't support adding/removing listeners, or changing the editing state in a listener @@ -52,7 +52,7 @@ void didChangeEditingState( private BaseInputConnection mDummyConnection; - // The View is only use for creating a dummy BaseInputConnection for setComposingRegion. The View + // The View is only used for creating a dummy BaseInputConnection for setComposingRegion. The View // needs to have a non-null Context. public ListenableEditingState(TextInputChannel.TextEditState configuration, View view) { super(); @@ -81,8 +81,8 @@ public void beginBatchEdit() { } if (mBatchEditNestDepth == 1 && !mListeners.isEmpty()) { mTextWhenBeginBatchEdit = toString(); - mSelectionStartWhenBeginBatchEdit = getSelecionStart(); - mSelectionEndWhenBeginBatchEdit = getSelecionEnd(); + mSelectionStartWhenBeginBatchEdit = getSelectionStart(); + mSelectionEndWhenBeginBatchEdit = getSelectionEnd(); mComposingStartWhenBeginBatchEdit = getComposingStart(); mComposingEndWhenBeginBatchEdit = getComposingEnd(); } @@ -104,8 +104,8 @@ public void endBatchEdit() { Log.v(TAG, "didFinishBatchEdit with " + String.valueOf(mListeners.size()) + " listener(s)"); final boolean textChanged = !toString().equals(mTextWhenBeginBatchEdit); final boolean selectionChanged = - mSelectionStartWhenBeginBatchEdit != getSelecionStart() - || mSelectionEndWhenBeginBatchEdit != getSelecionEnd(); + mSelectionStartWhenBeginBatchEdit != getSelectionStart() + || mSelectionEndWhenBeginBatchEdit != getSelectionEnd(); final boolean composingRegionChanged = mComposingStartWhenBeginBatchEdit != getComposingStart() || mComposingEndWhenBeginBatchEdit != getComposingEnd(); @@ -187,8 +187,8 @@ public SpannableStringBuilder replace( mToStringCache = null; } - final int selectionStart = getSelecionStart(); - final int selectionEnd = getSelecionEnd(); + final int selectionStart = getSelectionStart(); + final int selectionEnd = getSelectionEnd(); final int composingStart = getComposingStart(); final int composingEnd = getComposingEnd(); @@ -198,7 +198,7 @@ public SpannableStringBuilder replace( } final boolean selectionChanged = - getSelecionStart() != selectionStart || getSelecionEnd() != selectionEnd; + getSelectionStart() != selectionStart || getSelectionEnd() != selectionEnd; final boolean composingRegionChanged = getComposingStart() != composingStart || getComposingEnd() != composingEnd; notifyListenersIfNeeded(textChanged, selectionChanged, composingRegionChanged); @@ -224,11 +224,11 @@ private void notifyListenersIfNeeded( } } - public final int getSelecionStart() { + public final int getSelectionStart() { return Selection.getSelectionStart(this); } - public final int getSelecionEnd() { + public final int getSelectionEnd() { return Selection.getSelectionEnd(this); } diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index b29f95576afc1..9ddf71509becf 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -35,7 +35,7 @@ /** Android implementation of the text input plugin. */ public class TextInputPlugin implements ListenableEditingState.EditingStateWatcher { - private static final String TAG = "flutter"; + private static final String TAG = "TextInputPlugin"; @NonNull private final View mView; @NonNull private final InputMethodManager mImm; @@ -335,8 +335,8 @@ public InputConnection createInputConnection(View view, EditorInfo outAttrs) { InputConnectionAdaptor connection = new InputConnectionAdaptor( view, inputTarget.id, textInputChannel, keyProcessor, mEditable, outAttrs); - outAttrs.initialSelStart = mEditable.getSelecionStart(); - outAttrs.initialSelEnd = mEditable.getSelecionEnd(); + outAttrs.initialSelStart = mEditable.getSelectionStart(); + outAttrs.initialSelEnd = mEditable.getSelectionEnd(); lastInputConnection = connection; return lastInputConnection; @@ -565,8 +565,8 @@ public void didChangeEditingState( notifyValueChanged(mEditable.toString()); } - final int selectionStart = mEditable.getSelecionStart(); - final int selectionEnd = mEditable.getSelecionEnd(); + final int selectionStart = mEditable.getSelectionStart(); + final int selectionEnd = mEditable.getSelectionEnd(); final int composingStart = mEditable.getComposingStart(); final int composingEnd = mEditable.getComposingEnd(); // Framework needs to sent value first. @@ -605,7 +605,7 @@ public void didChangeEditingState( // ### Keep the AFM updated // // The autofill session connected to The AFM keeps a copy of the current state for each reported - // field in "AutofillVirtualStructure"(instead of holding a reference to those fields), so the AFM + // field in "AutofillVirtualStructure" (instead of holding a reference to those fields), so the AFM // needs to be notified when text changes if the client was part of the "AutofillVirtualStructure" // previously reported to the AFM. This step is essential for triggering autofill save. This is // done in #didChangeEditingState by calling #notifyValueChanged. diff --git a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java index 238bc59f0c4bd..d542a27e151bb 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java @@ -49,8 +49,8 @@ public void testBatchEditing() { assertFalse(listener.selectionChanged); assertFalse(listener.composingRegionChanged); - assertEquals(-1, editingState.getSelecionStart()); - assertEquals(-1, editingState.getSelecionEnd()); + assertEquals(-1, editingState.getSelectionStart()); + assertEquals(-1, editingState.getSelectionEnd()); listener.reset(); From b7d48b9d0fb5a17f15f77b257b08ce5478744ddf Mon Sep 17 00:00:00 2001 From: LongCat is Looong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 10 Nov 2020 16:42:41 -0800 Subject: [PATCH 18/18] format --- .../io/flutter/plugin/editing/TextInputPlugin.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 9ddf71509becf..a68487a3b085e 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -605,10 +605,11 @@ public void didChangeEditingState( // ### Keep the AFM updated // // The autofill session connected to The AFM keeps a copy of the current state for each reported - // field in "AutofillVirtualStructure" (instead of holding a reference to those fields), so the AFM - // needs to be notified when text changes if the client was part of the "AutofillVirtualStructure" - // previously reported to the AFM. This step is essential for triggering autofill save. This is - // done in #didChangeEditingState by calling #notifyValueChanged. + // field in "AutofillVirtualStructure" (instead of holding a reference to those fields), so the + // AFM needs to be notified when text changes if the client was part of the + // "AutofillVirtualStructure" previously reported to the AFM. This step is essential for + // triggering autofill save. This is done in #didChangeEditingState by calling + // #notifyValueChanged. // // Additionally when the text input plugin receives a new TextInputConfiguration, // AutofillManager#notifyValueChanged will be called on all the autofillable fields contained in