diff --git a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java index 93ec036b75ddf..525e2efe8f847 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -37,10 +37,46 @@ class InputConnectionAdaptor extends BaseInputConnection { private int mBatchCount; private InputMethodManager mImm; private final Layout mLayout; - // Used to determine if Samsung-specific hacks should be applied. private final boolean isSamsung; + private boolean mRepeatCheckNeeded = false; + private TextEditingValue mLastSentTextEditngValue; + // 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(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); + } + } + @SuppressWarnings("deprecation") public InputConnectionAdaptor( View view, @@ -76,15 +112,42 @@ private void updateEditingState() { // If the IME is in the middle of a batch edit, then wait until it completes. if (mBatchCount > 0) return; - int selectionStart = Selection.getSelectionStart(mEditable); - int selectionEnd = Selection.getSelectionEnd(mEditable); - int composingStart = BaseInputConnection.getComposingSpanStart(mEditable); - int composingEnd = BaseInputConnection.getComposingSpanEnd(mEditable); + 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. + if (mRepeatCheckNeeded && currentValue.equals(mLastSentTextEditngValue)) { + return; + } - mImm.updateSelection(mFlutterView, selectionStart, selectionEnd, composingStart, composingEnd); + mImm.updateSelection( + mFlutterView, + currentValue.selectionStart, + currentValue.selectionEnd, + currentValue.composingStart, + currentValue.composingEnd); textInputChannel.updateEditingState( - mClient, mEditable.toString(), selectionStart, selectionEnd, composingStart, composingEnd); + mClient, + currentValue.text, + currentValue.selectionStart, + currentValue.selectionEnd, + currentValue.composingStart, + currentValue.composingEnd); + + mRepeatCheckNeeded = true; + mLastSentTextEditngValue = 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; } @Override @@ -109,7 +172,7 @@ public boolean endBatchEdit() { @Override public boolean commitText(CharSequence text, int newCursorPosition) { boolean result = super.commitText(text, newCursorPosition); - updateEditingState(); + markDirty(); return result; } @@ -118,14 +181,21 @@ public boolean deleteSurroundingText(int beforeLength, int afterLength) { if (Selection.getSelectionStart(mEditable) == -1) return true; boolean result = super.deleteSurroundingText(beforeLength, afterLength); - updateEditingState(); + 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); - updateEditingState(); + markDirty(); return result; } @@ -137,7 +207,7 @@ public boolean setComposingText(CharSequence text, int newCursorPosition) { } else { result = super.setComposingText(text, newCursorPosition); } - updateEditingState(); + markDirty(); return result; } @@ -159,7 +229,7 @@ public boolean finishComposingText() { } } - updateEditingState(); + markDirty(); return result; } @@ -173,6 +243,13 @@ public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { return extractedText; } + @Override + public boolean clearMetaKeyStates(int states) { + boolean result = super.clearMetaKeyStates(states); + markDirty(); + return result; + } + // Detect if the keyboard is a Samsung keyboard, where we apply Samsung-specific hacks to // fix critical bugs that make the keyboard otherwise unusable. See finishComposingText() for // more details. @@ -197,7 +274,7 @@ private boolean isSamsung() { @Override public boolean setSelection(int start, int end) { boolean result = super.setSelection(start, end); - updateEditingState(); + markDirty(); return result; } @@ -219,6 +296,7 @@ private static int clampIndexToEditable(int index, Editable editable) { @Override public boolean sendKeyEvent(KeyEvent event) { + markDirty(); if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { int selStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable); @@ -344,6 +422,7 @@ public boolean sendKeyEvent(KeyEvent event) { @Override public boolean performContextMenuAction(int id) { + markDirty(); if (id == android.R.id.selectAll) { setSelection(0, mEditable.length()); return true; @@ -397,6 +476,7 @@ public boolean performContextMenuAction(int id) { @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 9d41dc5b52902..17ccea06728de 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -322,6 +322,10 @@ void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { } // 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. if (!restartAlwaysRequired && !mRestartInputPending) { mImm.updateSelection( 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 12d042c0237f6..bf0f513a0835e 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -270,6 +270,49 @@ 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); + } + private static final String SAMPLE_TEXT = "Lorem ipsum dolor sit amet," + "\nconsectetur adipiscing elit."; @@ -285,4 +328,35 @@ private static InputConnectionAdaptor sampleInputConnectionAdaptor(Editable edit TextInputChannel textInputChannel = mock(TextInputChannel.class); return new InputConnectionAdaptor(testView, client, textInputChannel, editable, null); } + + private class TestTextInputChannel extends TextInputChannel { + public TestTextInputChannel(DartExecutor dartExecutor) { + super(dartExecutor); + } + + public int inputClientId; + public String text; + public int selectionStart; + public int selectionEnd; + public int composingStart; + public int composingEnd; + public int updateEditingStateInvocations = 0; + + @Override + public void updateEditingState( + int inputClientId, + String text, + int selectionStart, + int selectionEnd, + int composingStart, + int composingEnd) { + this.inputClientId = inputClientId; + this.text = text; + this.selectionStart = selectionStart; + this.selectionEnd = selectionEnd; + this.composingStart = composingStart; + this.composingEnd = composingEnd; + updateEditingStateInvocations++; + } + } }