From 469242b87c4bd48f7d20b593d6550f4e48880c58 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 10 Nov 2020 20:07:58 -0800 Subject: [PATCH] Revert "[Android Text Input] Make the editing state listenable and allow batch edits (#21534)" This reverts commit 81f219c59c62078f52259972b63f0e29e9ee5c41. --- ci/licenses_golden/licenses_flutter | 1 - shell/platform/android/BUILD.gn | 2 - .../systemchannels/TextInputChannel.java | 47 +- .../editing/InputConnectionAdaptor.java | 271 +++++------ .../editing/ListenableEditingState.java | 247 ---------- .../plugin/editing/TextInputPlugin.java | 403 +++++++--------- shell/platform/android/test/README.md | 7 +- .../test/io/flutter/FlutterTestSuite.java | 2 - .../editing/InputConnectionAdaptorTest.java | 285 +++--------- .../editing/ListenableEditingStateTest.java | 408 ----------------- .../plugin/editing/TextInputPluginTest.java | 431 ++---------------- 11 files changed, 416 insertions(+), 1688 deletions(-) delete mode 100644 shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java delete mode 100644 shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index f3a32953a8b5b..638ae3138506c 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -809,7 +809,6 @@ 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/BUILD.gn b/shell/platform/android/BUILD.gn index a7ebd146db06e..56bf9259a4465 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -217,7 +217,6 @@ 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", @@ -474,7 +473,6 @@ 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/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index d8159ca2c6204..b05921c84bb1b 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -675,60 +675,17 @@ public static TextEditState fromJson(@NonNull JSONObject textEditState) throws J return new TextEditState( textEditState.getString("text"), textEditState.getInt("selectionBase"), - textEditState.getInt("selectionExtent"), - textEditState.getInt("composingBase"), - textEditState.getInt("composingExtent")); + textEditState.getInt("selectionExtent")); } @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, - int composingStart, - int composingEnd) - throws IndexOutOfBoundsException { - - if ((selectionStart != -1 || selectionEnd != -1) - && (selectionStart < 0 || selectionStart > selectionEnd)) { - throw new IndexOutOfBoundsException( - "invalid selection: (" - + String.valueOf(selectionStart) - + ", " - + String.valueOf(selectionEnd) - + ")"); - } - - if ((composingStart != -1 || composingEnd != -1) - && (composingStart < 0 || composingStart >= composingEnd)) { - throw new IndexOutOfBoundsException( - "invalid composing range: (" - + String.valueOf(composingStart) - + ", " - + String.valueOf(composingEnd) - + ")"); - } - - if (composingStart > text.length()) { - throw new IndexOutOfBoundsException( - "invalid composing start: " + String.valueOf(composingStart)); - } - - if (selectionStart > text.length()) { - throw new IndexOutOfBoundsException( - "invalid selection start: " + String.valueOf(selectionStart)); - } + public TextEditState(@NonNull String text, int selectionStart, int selectionEnd) { 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 7615126b135e2..26cb5d9d984e7 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -31,23 +31,68 @@ import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.systemchannels.TextInputChannel; -class InputConnectionAdaptor extends BaseInputConnection - implements ListenableEditingState.EditingStateWatcher { - private static final String TAG = "InputConnectionAdaptor"; - +class InputConnectionAdaptor extends BaseInputConnection { private final View mFlutterView; private final int mClient; private final TextInputChannel textInputChannel; private final AndroidKeyProcessor keyProcessor; - private final ListenableEditingState mEditable; + 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; private FlutterTextUtils flutterTextUtils; + // 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); + } + + @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( @@ -55,7 +100,7 @@ public InputConnectionAdaptor( int client, TextInputChannel textInputChannel, AndroidKeyProcessor keyProcessor, - ListenableEditingState editable, + Editable editable, EditorInfo editorInfo, FlutterJNI flutterJNI) { super(view, true); @@ -63,8 +108,8 @@ public InputConnectionAdaptor( mClient = client; this.textInputChannel = textInputChannel; mEditable = editable; - mEditable.addEditingStateListener(this); mEditorInfo = editorInfo; + mBatchCount = 0; this.keyProcessor = keyProcessor; this.flutterTextUtils = new FlutterTextUtils(flutterJNI); // We create a dummy Layout with max width so that the selection @@ -79,6 +124,8 @@ public InputConnectionAdaptor( 0.0f, false); mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + + isSamsung = isSamsung(); } public InputConnectionAdaptor( @@ -86,45 +133,52 @@ public InputConnectionAdaptor( int client, TextInputChannel textInputChannel, AndroidKeyProcessor keyProcessor, - ListenableEditingState editable, + Editable editable, EditorInfo editorInfo) { this(view, client, textInputChannel, keyProcessor, editable, editorInfo, new FlutterJNI()); } - private ExtractedText getExtractedText(ExtractedTextRequest request) { - mExtractedText.startOffset = 0; - mExtractedText.partialStartOffset = -1; - mExtractedText.partialEndOffset = -1; - mExtractedText.selectionStart = mEditable.getSelectionStart(); - mExtractedText.selectionEnd = mEditable.getSelectionEnd(); - mExtractedText.text = - request == null || (request.flags & GET_TEXT_WITH_STYLES) == 0 - ? mEditable.toString() - : mEditable; - return mExtractedText; - } + // 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; - private CursorAnchorInfo getCursorAnchorInfo() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return null; - } - if (mCursorAnchorInfoBuilder == null) { - mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); - } else { - mCursorAnchorInfoBuilder.reset(); - } + TextEditingValue currentValue = new TextEditingValue(mEditable); - mCursorAnchorInfoBuilder.setSelectionRange( - mEditable.getSelectionStart(), mEditable.getSelectionEnd()); - final int composingStart = mEditable.getComposingStart(); - final int composingEnd = mEditable.getComposingEnd(); - if (composingStart >= 0 && composingEnd > composingStart) { - mCursorAnchorInfoBuilder.setComposingText( - composingStart, mEditable.toString().subSequence(composingStart, composingEnd)); - } else { - mCursorAnchorInfoBuilder.setComposingText(-1, ""); + // 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; } - return mCursorAnchorInfoBuilder.build(); + + mImm.updateSelection( + mFlutterView, + currentValue.selectionStart, + currentValue.selectionEnd, + currentValue.composingStart, + currentValue.composingEnd); + + textInputChannel.updateEditingState( + 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 @@ -134,112 +188,99 @@ public Editable getEditable() { @Override public boolean beginBatchEdit() { - mEditable.beginBatchEdit(); + mBatchCount++; return super.beginBatchEdit(); } @Override public boolean endBatchEdit() { boolean result = super.endBatchEdit(); - mEditable.endBatchEdit(); + mBatchCount--; + updateEditingState(); return result; } @Override public boolean commitText(CharSequence text, int newCursorPosition) { - final boolean result = super.commitText(text, newCursorPosition); + boolean result = super.commitText(text, newCursorPosition); + markDirty(); return result; } @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - if (mEditable.getSelectionStart() == -1) { - return true; - } + if (Selection.getSelectionStart(mEditable) == -1) return true; - final boolean result = super.deleteSurroundingText(beforeLength, afterLength); + 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) { - final boolean result = super.setComposingRegion(start, end); + boolean result = super.setComposingRegion(start, end); + markDirty(); return result; } @Override public boolean setComposingText(CharSequence text, int newCursorPosition) { boolean result; - beginBatchEdit(); if (text.length() == 0) { result = super.commitText(text, newCursorPosition); } else { result = super.setComposingText(text, newCursorPosition); } - endBatchEdit(); + markDirty(); return result; } @Override public boolean finishComposingText() { - final boolean result = super.finishComposingText(); + 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); + } + } + + markDirty(); 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) { - 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")); - } - // Enables text monitoring if the relevant flag is set. See - // InputConnectionAdaptor#didChangeEditingState. - mExtractRequest = textMonitor ? request : null; - return getExtractedText(request); - } - - @Override - public boolean requestCursorUpdates(int cursorUpdateMode) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return false; - } - if ((cursorUpdateMode & CURSOR_UPDATE_IMMEDIATE) != 0) { - 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 = updated; - return true; + ExtractedText extractedText = new ExtractedText(); + extractedText.selectionStart = Selection.getSelectionStart(mEditable); + extractedText.selectionEnd = Selection.getSelectionEnd(mEditable); + extractedText.text = mEditable.toString(); + return extractedText; } @Override public boolean clearMetaKeyStates(int states) { boolean result = super.clearMetaKeyStates(states); + markDirty(); 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. @@ -263,9 +304,9 @@ private boolean isSamsung() { @Override public boolean setSelection(int start, int end) { - beginBatchEdit(); boolean result = super.setSelection(start, end); - endBatchEdit(); + markDirty(); + updateEditingState(); return result; } @@ -295,6 +336,7 @@ 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); @@ -307,6 +349,7 @@ public boolean sendKeyEvent(KeyEvent event) { // Delete the selection. Selection.setSelection(mEditable, selStart); mEditable.delete(selStart, selEnd); + updateEditingState(); return true; } return false; @@ -397,13 +440,7 @@ 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) { + markDirty(); if (id == android.R.id.selectAll) { setSelection(0, mEditable.length()); return true; @@ -463,6 +500,7 @@ public boolean performPrivateCommand(String action, Bundle data) { @Override public boolean performEditorAction(int actionCode) { + markDirty(); switch (actionCode) { case EditorInfo.IME_ACTION_NONE: textInputChannel.newline(mClient); @@ -492,37 +530,4 @@ public boolean performEditorAction(int actionCode) { } return true; } - - // -------- Start: ListenableEditingState watcher implementation ------- - @Override - public void didChangeEditingState( - boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { - // 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.getSelectionStart(), - mEditable.getSelectionEnd(), - mEditable.getComposingStart(), - mEditable.getComposingEnd()); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return; - } - if (mExtractRequest != null) { - mImm.updateExtractedText( - mFlutterView, mExtractRequest.token, getExtractedText(mExtractRequest)); - } - if (mMonitorCursorUpdate) { - final CursorAnchorInfo info = getCursorAnchorInfo(); - mImm.updateCursorAnchorInfo(mFlutterView, info); - } - } - // -------- 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 deleted file mode 100644 index 49f09bb996900..0000000000000 --- a/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java +++ /dev/null @@ -1,247 +0,0 @@ -// 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; -import android.text.Selection; -import android.text.SpannableStringBuilder; -import android.view.View; -import android.view.inputmethod.BaseInputConnection; -import io.flutter.Log; -import io.flutter.embedding.engine.systemchannels.TextInputChannel; -import java.util.ArrayList; - -/// 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 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. -/// -/// 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. -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); - } - - 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 - // callback for now. - private int mChangeNotificationDepth = 0; - private ArrayList mListeners = new ArrayList<>(); - private ArrayList mPendingListeners = new ArrayList<>(); - - private String mToStringCache; - - private String mTextWhenBeginBatchEdit; - private int mSelectionStartWhenBeginBatchEdit; - private int mSelectionEndWhenBeginBatchEdit; - private int mComposingStartWhenBeginBatchEdit; - private int mComposingEndWhenBeginBatchEdit; - - private BaseInputConnection mDummyConnection; - - // 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(); - if (configuration != null) { - setEditingState(configuration); - } - - Editable self = this; - mDummyConnection = - new BaseInputConnection(view, true) { - @Override - public Editable getEditable() { - return self; - } - }; - } - - /// Starts a new batch edit during which change notifications will be put on hold until all batch - /// edits end. - /// - /// Batch edits 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 = getSelectionStart(); - mSelectionEndWhenBeginBatchEdit = getSelectionEnd(); - mComposingStartWhenBeginBatchEdit = getComposingStart(); - mComposingEndWhenBeginBatchEdit = getComposingEnd(); - } - } - - /// Ends the current batch edit and flush pending change notifications if the current 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); - } - - if (!mListeners.isEmpty()) { - Log.v(TAG, "didFinishBatchEdit with " + String.valueOf(mListeners.size()) + " listener(s)"); - final boolean textChanged = !toString().equals(mTextWhenBeginBatchEdit); - final boolean selectionChanged = - mSelectionStartWhenBeginBatchEdit != getSelectionStart() - || mSelectionEndWhenBeginBatchEdit != getSelectionEnd(); - final boolean composingRegionChanged = - mComposingStartWhenBeginBatchEdit != getComposingStart() - || mComposingEndWhenBeginBatchEdit != getComposingEnd(); - - notifyListenersIfNeeded(textChanged, selectionChanged, composingRegionChanged); - } - } - - mListeners.addAll(mPendingListeners); - mPendingListeners.clear(); - mBatchEditNestDepth--; - } - - /// 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); - } else { - mDummyConnection.setComposingRegion(composingStart, 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(), newState.text); - - if (newState.selectionStart >= 0 && newState.selectionEnd >= newState.selectionStart) { - Selection.setSelection(this, newState.selectionStart, newState.selectionEnd); - } else { - Selection.removeSelection(this); - } - setComposingRange(newState.composingStart, newState.composingEnd); - endBatchEdit(); - } - - 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. - 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) { - if (mChangeNotificationDepth > 0) { - Log.e(TAG, "removing a listener " + listener.toString() + " in a listener callback"); - } - mListeners.remove(listener); - if (mBatchEditNestDepth > 0) { - mPendingListeners.remove(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); - } - if (textChanged) { - mToStringCache = null; - } - - final int selectionStart = getSelectionStart(); - final int selectionEnd = getSelectionEnd(); - final int composingStart = getComposingStart(); - final int composingEnd = getComposingEnd(); - - final SpannableStringBuilder editable = super.replace(start, end, tb, tbstart, tbend); - if (mBatchEditNestDepth > 0) { - return editable; - } - - final boolean selectionChanged = - getSelectionStart() != selectionStart || getSelectionEnd() != selectionEnd; - final boolean composingRegionChanged = - getComposingStart() != composingStart || getComposingEnd() != composingEnd; - 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); - } - } - } - - public final int getSelectionStart() { - return Selection.getSelectionStart(this); - } - - public final int getSelectionEnd() { - return Selection.getSelectionEnd(this); - } - - public final int getComposingStart() { - return BaseInputConnection.getComposingSpanStart(this); - } - - public final int getComposingEnd() { - return BaseInputConnection.getComposingSpanEnd(this); - } - - @Override - public String toString() { - return mToStringCache != null ? mToStringCache : (mToStringCache = super.toString()); - } -} diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index a68487a3b085e..b58cb8c67c399 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -12,6 +12,7 @@ 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; @@ -19,6 +20,7 @@ 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; @@ -26,17 +28,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import io.flutter.Log; import io.flutter.embedding.android.AndroidKeyProcessor; 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 implements ListenableEditingState.EditingStateWatcher { - private static final String TAG = "TextInputPlugin"; - +public class TextInputPlugin { @NonNull private final View mView; @NonNull private final InputMethodManager mImm; @NonNull private final AutofillManager afm; @@ -44,7 +42,7 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch @NonNull private InputTarget inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); @Nullable private TextInputChannel.Configuration configuration; @Nullable private SparseArray mAutofillConfigurations; - @Nullable private ListenableEditingState mEditable; + @Nullable private Editable mEditable; private boolean mRestartInputPending; @Nullable private InputConnection lastInputConnection; @NonNull private PlatformViewsController platformViewsController; @@ -53,9 +51,6 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch private ImeSyncDeferringInsetsCallback imeSyncCallback; private AndroidKeyProcessor keyProcessor; - // 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 @@ -225,10 +220,6 @@ public void unlockPlatformViewInputConnection() { public void destroy() { platformViewsController.detachTextInputPlugin(); textInputChannel.setTextInputMethodHandler(null); - notifyViewExited(); - if (mEditable != null) { - mEditable.removeEditingStateListener(this); - } if (imeSyncCallback != null) { imeSyncCallback.remove(); } @@ -335,8 +326,8 @@ public InputConnection createInputConnection(View view, EditorInfo outAttrs) { InputConnectionAdaptor connection = new InputConnectionAdaptor( view, inputTarget.id, textInputChannel, keyProcessor, mEditable, outAttrs); - outAttrs.initialSelStart = mEditable.getSelectionStart(); - outAttrs.initialSelEnd = mEditable.getSelectionEnd(); + outAttrs.initialSelStart = Selection.getSelectionStart(mEditable); + outAttrs.initialSelEnd = Selection.getSelectionEnd(mEditable); lastInputConnection = connection; return lastInputConnection; @@ -382,26 +373,51 @@ 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. - notifyViewExited(); inputTarget = new InputTarget(InputTarget.Type.FRAMEWORK_CLIENT, client); - - if (mEditable != null) { - mEditable.removeEditingStateListener(this); - } - mEditable = - new ListenableEditingState( - configuration.autofill != null ? configuration.autofill.editState : null, mView); 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) { @@ -416,14 +432,43 @@ 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) { - 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. - if (restartAlwaysRequired || mRestartInputPending) { + // Always replace the contents of mEditable if the text differs + if (!state.text.equals(mEditable.toString())) { + mEditable.replace(0, mEditable.length(), state.text); + } + 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. + 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; } @@ -473,200 +518,17 @@ 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"); - } - - @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 - // 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 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. - // 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); - notifyViewExited(); - updateAutofillConfigurationIfNeeded(null); - inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); - unlockPlatformViewInputConnection(); - 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.getSelectionStart(); - final int selectionEnd = mEditable.getSelectionEnd(); - 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()); - 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 ------- - // ### 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; - } - + notifyViewExited(); this.configuration = configuration; - if (configuration == null || configuration.autofill == null) { + final TextInputChannel.Configuration[] configurations = configuration.fields; + + if (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) { @@ -675,17 +537,19 @@ private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration } else { for (TextInputChannel.Configuration config : configurations) { TextInputChannel.Configuration.Autofill autofill = config.autofill; - if (autofill != null) { - mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config); - afm.notifyValueChanged( - mView, - autofill.uniqueIdentifier.hashCode(), - AutofillValue.forText(autofill.editState.text)); + if (autofill == null) { + continue; } + + 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; @@ -704,13 +568,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); - // 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. + // Some autofill services expect child structures to be visible. + // Reports the real size of the child if it's the current client. if (triggerIdentifier.hashCode() == autofillId && lastClientRect != null) { child.setDimens( lastClientRect.left, @@ -719,10 +583,9 @@ public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags 0, lastClientRect.width(), lastClientRect.height()); - child.setAutofillValue(AutofillValue.forText(mEditable)); } else { + // Reports a fake dimension that's still visible. child.setDimens(0, 0, 0, 0, 1, 1); - child.setAutofillValue(AutofillValue.forText(autofill.editState.text)); } } } @@ -749,7 +612,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(), -1, -1); + new TextInputChannel.TextEditState(value, value.length(), value.length()); // The value of the currently focused text field needs to be updated. if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) { @@ -760,5 +623,83 @@ public void autofill(SparseArray values) { textInputChannel.updateEditingStateWithTag(inputTarget.id, editingValues); } - // -------- End: Autofill ------- + + // 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; + } } diff --git a/shell/platform/android/test/README.md b/shell/platform/android/test/README.md index 2958b4323f6f4..9817c846587a5 100644 --- a/shell/platform/android/test/README.md +++ b/shell/platform/android/test/README.md @@ -14,10 +14,9 @@ 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. 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. + test jar. +3. Add your class 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=]`. diff --git a/shell/platform/android/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java index 635b6680475d0..f440f736589e1 100644 --- a/shell/platform/android/test/io/flutter/FlutterTestSuite.java +++ b/shell/platform/android/test/io/flutter/FlutterTestSuite.java @@ -30,7 +30,6 @@ 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; @@ -71,7 +70,6 @@ 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 feec49b017e75..a2d8eb963a42c 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -2,7 +2,6 @@ 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; @@ -15,22 +14,17 @@ import static org.mockito.Mockito.when; import android.content.ClipboardManager; -import android.content.Context; import android.content.res.AssetManager; -import android.os.Build; 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.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.android.AndroidKeyProcessor; import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.dart.DartExecutor; @@ -48,15 +42,9 @@ 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, InputConnectionAdaptorTest.TestImm.class}) +@Config(manifest = Config.NONE, shadows = ShadowClipboardManager.class) @RunWith(RobolectricTestRunner.class) public class InputConnectionAdaptorTest { // Verifies the method and arguments for a captured method call. @@ -82,8 +70,8 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { int inputTargetId = 0; TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState mEditable = new ListenableEditingState(null, testView); - ListenableEditingState spyEditable = spy(mEditable); + 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; @@ -100,7 +88,7 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { @Test public void testPerformContextMenuAction_selectAll() { int selStart = 5; - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); boolean didConsume = adaptor.performContextMenuAction(android.R.id.selectAll); @@ -116,7 +104,7 @@ public void testPerformContextMenuAction_cut() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); int selStart = 6; int selEnd = 11; - ListenableEditingState editable = sampleEditable(selStart, selEnd); + Editable editable = sampleEditable(selStart, selEnd); CharSequence textToBeCut = editable.subSequence(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); @@ -134,7 +122,7 @@ public void testPerformContextMenuAction_copy() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); int selStart = 6; int selEnd = 11; - ListenableEditingState editable = sampleEditable(selStart, selEnd); + Editable editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); assertFalse(clipboardManager.hasText()); @@ -154,7 +142,7 @@ public void testPerformContextMenuAction_paste() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); String textToBePasted = "deadbeef"; clipboardManager.setText(textToBePasted); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); boolean didConsume = adaptor.performContextMenuAction(android.R.id.paste); @@ -171,7 +159,7 @@ public void testPerformPrivateCommand_dataIsNull() throws JSONException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -199,7 +187,7 @@ public void testPerformPrivateCommand_dataIsByteArray() throws JSONException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -233,7 +221,7 @@ public void testPerformPrivateCommand_dataIsByte() throws JSONException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -265,7 +253,7 @@ public void testPerformPrivateCommand_dataIsCharArray() throws JSONException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -300,7 +288,7 @@ public void testPerformPrivateCommand_dataIsChar() throws JSONException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -332,7 +320,7 @@ public void testPerformPrivateCommand_dataIsCharSequenceArray() throws JSONExcep DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -368,7 +356,7 @@ public void testPerformPrivateCommand_dataIsCharSequence() throws JSONException DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -402,7 +390,7 @@ public void testPerformPrivateCommand_dataIsFloat() throws JSONException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -434,7 +422,7 @@ public void testPerformPrivateCommand_dataIsFloatArray() throws JSONException { DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJNI, mock(AssetManager.class))); TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -464,7 +452,7 @@ public void testPerformPrivateCommand_dataIsFloatArray() throws JSONException { public void testSendKeyEvent_shiftKeyUpCancelsSelection() { int selStart = 5; int selEnd = 10; - ListenableEditingState editable = sampleEditable(selStart, selEnd); + Editable editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftKeyUp = new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT); @@ -478,7 +466,7 @@ public void testSendKeyEvent_shiftKeyUpCancelsSelection() { @Test public void testSendKeyEvent_leftKeyMovesCaretLeft() { int selStart = 5; - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -492,7 +480,7 @@ public void testSendKeyEvent_leftKeyMovesCaretLeft() { @Test public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { int selStart = 75; - ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -635,7 +623,7 @@ public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { int selStart = 5; int selEnd = 40; - ListenableEditingState editable = sampleEditable(selStart, selEnd); + Editable editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -649,7 +637,7 @@ public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { @Test public void testSendKeyEvent_shiftLeftKeyStartsSelectionLeft() { int selStart = 5; - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftLeftKeyDown = @@ -665,7 +653,7 @@ public void testSendKeyEvent_shiftLeftKeyStartsSelectionLeft() { @Test public void testSendKeyEvent_rightKeyMovesCaretRight() { int selStart = 5; - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -683,7 +671,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 = "🇷🇷🇷🇷🇷🇷🇷"; - ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_REGION_TEXT); + Editable editable = sampleEditable(selStart, selStart, SAMPLE_REGION_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -717,7 +705,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexRegion() { @Test public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { int selStart = 0; - ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -853,7 +841,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { public void testSendKeyEvent_rightKeyExtendsSelectionRight() { int selStart = 5; int selEnd = 40; - ListenableEditingState editable = sampleEditable(selStart, selEnd); + Editable editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -867,7 +855,7 @@ public void testSendKeyEvent_rightKeyExtendsSelectionRight() { @Test public void testSendKeyEvent_shiftRightKeyStartsSelectionRight() { int selStart = 5; - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftRightKeyDown = @@ -883,7 +871,7 @@ public void testSendKeyEvent_shiftRightKeyStartsSelectionRight() { @Test public void testSendKeyEvent_upKeyMovesCaretUp() { int selStart = SAMPLE_TEXT.indexOf('\n') + 4; - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent upKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP); @@ -898,7 +886,7 @@ public void testSendKeyEvent_upKeyMovesCaretUp() { @Test public void testSendKeyEvent_downKeyMovesCaretDown() { int selStart = 4; - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN); @@ -913,8 +901,7 @@ public void testSendKeyEvent_downKeyMovesCaretDown() { @Test public void testMethod_getExtractedText() { int selStart = 5; - - ListenableEditingState editable = sampleEditable(selStart, selStart); + Editable editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); ExtractedText extractedText = adaptor.getExtractedText(null, 0); @@ -925,113 +912,53 @@ public void testMethod_getExtractedText() { } @Test - public void testExtractedText_monitoring() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return; - } - ListenableEditingState editable = sampleEditable(5, 5); + 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); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - InputConnectionAdaptor adaptor = - new InputConnectionAdaptor( - testView, - 1, - mock(TextInputChannel.class), - mockKeyProcessor, - 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); - } - - @Test - public void testCursorAnchorInfo() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return; - } + 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; - AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - ListenableEditingState editable = sampleEditable(5, 5); - View testView = new View(RuntimeEnvironment.application); - InputConnectionAdaptor adaptor = + InputConnectionAdaptor inputConnectionAdaptor = new InputConnectionAdaptor( - testView, - 1, - mock(TextInputChannel.class), - mockKeyProcessor, - 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); + testView, inputTargetId, textInputChannel, mockKeyProcessor, 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 testSendKeyEvent_delKeyDeletesBackward() { int selStart = 29; - ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_RTL_TEXT); + Editable editable = sampleEditable(selStart, selStart, SAMPLE_RTL_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); @@ -1052,7 +979,7 @@ public void testSendKeyEvent_delKeyDeletesBackward() { @Test public void testSendKeyEvent_delKeyDeletesBackwardComplexEmojis() { int selStart = 75; - ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); @@ -1193,7 +1120,7 @@ public void testSendKeyEvent_delKeyDeletesBackwardComplexEmojis() { @Test public void testDoesNotConsumeBackButton() { - ListenableEditingState editable = sampleEditable(0, 0); + Editable editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); FakeKeyEvent keyEvent = new FakeKeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK); @@ -1227,24 +1154,19 @@ 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, new View(RuntimeEnvironment.application)); - sample.replace(0, 0, SAMPLE_TEXT); + private static Editable sampleEditable(int selStart, int selEnd) { + SpannableStringBuilder sample = new SpannableStringBuilder(SAMPLE_TEXT); Selection.setSelection(sample, selStart, selEnd); return sample; } - private static ListenableEditingState sampleEditable(int selStart, int selEnd, String text) { - ListenableEditingState sample = - new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - sample.replace(0, 0, text); + private static Editable sampleEditable(int selStart, int selEnd, String text) { + SpannableStringBuilder sample = new SpannableStringBuilder(text); Selection.setSelection(sample, selStart, selEnd); return sample; } - private static InputConnectionAdaptor sampleInputConnectionAdaptor( - ListenableEditingState editable) { + private static InputConnectionAdaptor sampleInputConnectionAdaptor(Editable editable) { View testView = new View(RuntimeEnvironment.application); int client = 0; TextInputChannel textInputChannel = mock(TextInputChannel.class); @@ -1299,61 +1221,4 @@ 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 deleted file mode 100644 index d542a27e151bb..0000000000000 --- a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java +++ /dev/null @@ -1,408 +0,0 @@ -package io.flutter.plugin.editing; - -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.android.AndroidKeyProcessor; -import io.flutter.embedding.engine.systemchannels.TextInputChannel; -import java.util.ArrayList; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.annotation.Config; - -@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.isCalled()); - assertTrue(listener.textChanged); - assertFalse(listener.selectionChanged); - assertFalse(listener.composingRegionChanged); - - assertEquals(-1, editingState.getSelectionStart()); - assertEquals(-1, editingState.getSelectionEnd()); - - listener.reset(); - - // Batch edit depth = 1. - editingState.beginBatchEdit(); - editingState.replace(0, editingState.length(), "update1"); - assertFalse(listener.isCalled()); - // Batch edit depth = 2. - editingState.beginBatchEdit(); - editingState.replace(0, editingState.length(), "update2"); - inputConnection.setComposingRegion(0, editingState.length()); - assertFalse(listener.isCalled()); - // Batch edit depth = 1. - editingState.endBatchEdit(); - assertFalse(listener.isCalled()); - - // Batch edit depth = 2. - editingState.beginBatchEdit(); - assertFalse(listener.isCalled()); - inputConnection.setSelection(0, 0); - assertFalse(listener.isCalled()); - // Batch edit depth = 1. - editingState.endBatchEdit(); - assertFalse(listener.isCalled()); - - // Remove composing region. - inputConnection.finishComposingText(); - - // Batch edit depth = 0. Last endBatchEdit. - editingState.endBatchEdit(); - - // Now notify the listener. - assertTrue(listener.isCalled()); - 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.isCalled()); - - editingState.replace(0, editingState.length(), "text"); - 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.isCalled()); - editingState.endBatchEdit(); - assertTrue(listener.isCalled()); - } - - @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(); - - 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); - editingState.removeEditingStateListener(listener); - - listener.reset(); - // Now remove before endBatchEdit(); - editingState.beginBatchEdit(); - editingState.addEditingStateListener(listener); - editingState.replace(0, editingState.length(), "update"); - editingState.removeEditingStateListener(listener); - editingState.endBatchEdit(); - - assertFalse(listener.isCalled()); - } - - @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.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 ------- - - @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_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 AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - final InputConnectionAdaptor inputConnection = - new InputConnectionAdaptor( - testView, - 0, - mock(TextInputChannel.class), - mockKeyProcessor, - 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 = - new ListenableEditingState(null, new View(RuntimeEnvironment.application)); - final Listener listener = new Listener(); - final View testView = new View(RuntimeEnvironment.application); - final AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - final InputConnectionAdaptor inputConnection = - new InputConnectionAdaptor( - testView, - 0, - mock(TextInputChannel.class), - mockKeyProcessor, - editingState, - new EditorInfo()); - editingState.replace(0, editingState.length(), "initial text"); - - editingState.addEditingStateListener(listener); - - inputConnection.setSelection(0, 0); - - assertTrue(listener.isCalled()); - assertFalse(listener.textChanged); - assertTrue(listener.selectionChanged); - assertFalse(listener.composingRegionChanged); - - listener.reset(); - - inputConnection.setSelection(5, 5); - - assertTrue(listener.isCalled()); - 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 AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - final InputConnectionAdaptor inputConnection = - new InputConnectionAdaptor( - testView, - 0, - mock(TextInputChannel.class), - mockKeyProcessor, - editingState, - new EditorInfo()); - editingState.replace(0, editingState.length(), "initial text"); - - editingState.addEditingStateListener(listener); - - // setComposingRegion test. - inputConnection.setComposingRegion(1, 3); - assertTrue(listener.isCalled()); - 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.isCalled()); - assertTrue(listener.textChanged); - assertFalse(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - listener.reset(); - // setComposingText test: non-empty text, moves cursor. - inputConnection.setComposingText("composing2", 1); - assertTrue(listener.isCalled()); - assertTrue(listener.textChanged); - assertTrue(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - listener.reset(); - // setComposingText test: empty text. - inputConnection.setComposingText("", 1); - assertTrue(listener.isCalled()); - assertTrue(listener.textChanged); - assertTrue(listener.selectionChanged); - assertTrue(listener.composingRegionChanged); - - // finishComposingText test. - inputConnection.setComposingText("composing text", 1); - listener.reset(); - inputConnection.finishComposingText(); - assertTrue(listener.isCalled()); - 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 AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - final InputConnectionAdaptor inputConnection = - new InputConnectionAdaptor( - testView, - 0, - mock(TextInputChannel.class), - mockKeyProcessor, - editingState, - new EditorInfo()); - editingState.replace(0, editingState.length(), "initial text"); - - editingState.addEditingStateListener(listener); - } - // -------- End: Test InputMethods actions ------- - - public static class Listener implements ListenableEditingState.EditingStateWatcher { - public boolean isCalled() { - return timesCalled > 0; - } - - int timesCalled = 0; - boolean textChanged = false; - boolean selectionChanged = false; - boolean composingRegionChanged = false; - - @Override - public void didChangeEditingState( - boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) { - timesCalled++; - this.textChanged = textChanged; - this.selectionChanged = selectionChanged; - this.composingRegionChanged = composingRegionChanged; - } - - public void reset() { - timesCalled = 0; - 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 263a19abd212c..7cec8faafb6fd 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.gt; +import static org.mockito.AdditionalMatchers.geq; import static org.mockito.Matchers.anyInt; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; @@ -19,26 +19,20 @@ 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; -import android.text.InputType; -import android.text.Selection; import android.util.SparseIntArray; import android.view.KeyEvent; import android.view.View; 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; import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodSubtype; -import io.flutter.embedding.android.AndroidKeyProcessor; import io.flutter.embedding.android.FlutterView; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.FlutterJNI; @@ -66,13 +60,10 @@ 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, TextInputPluginTest.TestAfm.class}) +@Config(manifest = Config.NONE, shadows = TextInputPluginTest.TestImm.class) @RunWith(RobolectricTestRunner.class) public class TextInputPluginTest { @Mock FlutterJNI mockFlutterJni; @@ -129,138 +120,6 @@ 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, -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 framework", 1, 2, -1, -1)); - - 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. @@ -287,12 +146,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, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); // Verify that we haven't restarted the input. assertEquals(1, testImm.getRestartCount(testView)); @@ -326,13 +185,13 @@ public void setTextInputEditingState_alwaysSetEditableWhenDifferent() { // changed text, we should // always set the Editable contents. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("hello", 0, 0, -1, -1)); + testView, new TextInputChannel.TextEditState("hello", 0, 0)); 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, -1, -1)); + testView, new TextInputChannel.TextEditState("Shibuyawoo", 0, 0)); assertEquals(1, testImm.getRestartCount(testView)); assertTrue(textInputPlugin.getEditable().toString().equals("Shibuyawoo")); } @@ -373,12 +232,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, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); // Verify that we've restarted the input. assertEquals(2, testImm.getRestartCount(testView)); @@ -416,12 +275,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, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); // Verify that we've restarted the input. assertEquals(1, testImm.getRestartCount(testView)); @@ -452,7 +311,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, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); assertEquals(1, testImm.getRestartCount(testView)); } @@ -494,7 +353,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, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); ArgumentCaptor channelCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor bufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class); @@ -535,9 +394,6 @@ public void inputConnection_createsActionFromEnter() throws JSONException { @Test public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return; - } ShadowBuild.setManufacturer("samsung"); InputMethodSubtype inputMethodSubtype = new InputMethodSubtype(0, 0, /*locale=*/ "en", "", "", false, false); @@ -569,24 +425,22 @@ 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("text", 0, 0, -1, -1)); + testView, new TextInputChannel.TextEditState("", 0, 0)); InputConnection connection = textInputPlugin.createInputConnection(testView, new EditorInfo()); - connection.requestCursorUpdates( - InputConnection.CURSOR_UPDATE_MONITOR | InputConnection.CURSOR_UPDATE_IMMEDIATE); - connection.finishComposingText(); - assertEquals(-1, testImm.getLastCursorAnchorInfo().getComposingTextStart()); - assertEquals(0, testImm.getLastCursorAnchorInfo().getComposingText().length()); + if (Build.VERSION.SDK_INT >= 21) { + CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder(); + builder.setComposingText(-1, ""); + CursorAnchorInfo anchorInfo = builder.build(); + assertEquals(testImm.getLastCursorAnchorInfo(), anchorInfo); + } } - // -------- Start: Autofill Tests ------- @Test public void autofill_onProvideVirtualViewStructure() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; FlutterView testView = new FlutterView(RuntimeEnvironment.application); TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); @@ -594,12 +448,10 @@ 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, -1)); + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); final TextInputChannel.Configuration.Autofill autofill2 = new TextInputChannel.Configuration.Autofill( - "2", - new String[] {"HINT2", "EXTRA"}, - new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + "2", new String[] {"HINT2", "EXTRA"}, new TextInputChannel.TextEditState("", 0, 0)); final TextInputChannel.Configuration config1 = new TextInputChannel.Configuration( @@ -650,11 +502,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(), gt(0), gt(0)); + verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(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(), gt(0), gt(0)); + verify(children[1]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); } @Test @@ -669,7 +521,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, -1)); + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); // Autofill should still work without AutofillGroup. textInputPlugin.setTextInputClient( @@ -698,195 +550,9 @@ 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(), 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(); - final AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - InputConnectionAdaptor adaptor = - new InputConnectionAdaptor( - testView, - 0, - mock(TextInputChannel.class), - mockKeyProcessor, - (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); + verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); } - @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 = @@ -1147,49 +813,4 @@ 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) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - changeVirtualId = virtualId; - changeString = value.getTextValue().toString(); - } - - public void resetStates() { - finishState = null; - changeVirtualId = empty; - changeString = null; - enterId = empty; - exitId = empty; - } - } }