diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 93948761cd3b5..8a8bfefc08d67 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -809,6 +809,7 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/common/StringCod FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/FlutterTextUtils.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/localization/LocalizationPlugin.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/mouse/MouseCursorPlugin.java diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 91d2a368b1057..62b24fcd1ecda 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -216,6 +216,7 @@ android_java_sources = [ "io/flutter/plugin/editing/FlutterTextUtils.java", "io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java", "io/flutter/plugin/editing/InputConnectionAdaptor.java", + "io/flutter/plugin/editing/ListenableEditingState.java", "io/flutter/plugin/editing/TextInputPlugin.java", "io/flutter/plugin/localization/LocalizationPlugin.java", "io/flutter/plugin/mouse/MouseCursorPlugin.java", @@ -472,6 +473,7 @@ action("robolectric_tests") { "test/io/flutter/plugin/common/StandardMessageCodecTest.java", "test/io/flutter/plugin/common/StandardMethodCodecTest.java", "test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java", + "test/io/flutter/plugin/editing/ListenableEditingStateTest.java", "test/io/flutter/plugin/editing/TextInputPluginTest.java", "test/io/flutter/plugin/localization/LocalizationPluginTest.java", "test/io/flutter/plugin/mouse/MouseCursorPluginTest.java", diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index b05921c84bb1b..d8159ca2c6204 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -675,17 +675,60 @@ public static TextEditState fromJson(@NonNull JSONObject textEditState) throws J return new TextEditState( textEditState.getString("text"), textEditState.getInt("selectionBase"), - textEditState.getInt("selectionExtent")); + textEditState.getInt("selectionExtent"), + textEditState.getInt("composingBase"), + textEditState.getInt("composingExtent")); } @NonNull public final String text; public final int selectionStart; public final int selectionEnd; + public final int composingStart; + public final int composingEnd; + + public TextEditState( + @NonNull String text, + int selectionStart, + int selectionEnd, + 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 26cb5d9d984e7..7615126b135e2 100644 --- a/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java +++ b/shell/platform/android/io/flutter/plugin/editing/InputConnectionAdaptor.java @@ -31,68 +31,23 @@ import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.systemchannels.TextInputChannel; -class InputConnectionAdaptor extends BaseInputConnection { +class InputConnectionAdaptor extends BaseInputConnection + implements ListenableEditingState.EditingStateWatcher { + private static final String TAG = "InputConnectionAdaptor"; + private final View mFlutterView; private final int mClient; private final TextInputChannel textInputChannel; private final AndroidKeyProcessor keyProcessor; - private final Editable mEditable; + private final ListenableEditingState mEditable; private final EditorInfo mEditorInfo; - private int mBatchCount; + private ExtractedTextRequest mExtractRequest; + private boolean mMonitorCursorUpdate = false; + private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder; + private ExtractedText mExtractedText = new ExtractedText(); 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( @@ -100,7 +55,7 @@ public InputConnectionAdaptor( int client, TextInputChannel textInputChannel, AndroidKeyProcessor keyProcessor, - Editable editable, + ListenableEditingState editable, EditorInfo editorInfo, FlutterJNI flutterJNI) { super(view, true); @@ -108,8 +63,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 @@ -124,8 +79,6 @@ public InputConnectionAdaptor( 0.0f, false); mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - - isSamsung = isSamsung(); } public InputConnectionAdaptor( @@ -133,52 +86,45 @@ public InputConnectionAdaptor( int client, TextInputChannel textInputChannel, AndroidKeyProcessor keyProcessor, - Editable editable, + ListenableEditingState editable, EditorInfo editorInfo) { this(view, client, textInputChannel, keyProcessor, editable, editorInfo, new FlutterJNI()); } - // Send the current state of the editable to Flutter. - private void updateEditingState() { - // If the IME is in the middle of a batch edit, then wait until it completes. - if (mBatchCount > 0) return; - - TextEditingValue currentValue = new TextEditingValue(mEditable); + 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; + } - // 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; + private CursorAnchorInfo getCursorAnchorInfo() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return null; + } + if (mCursorAnchorInfoBuilder == null) { + mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); + } else { + mCursorAnchorInfoBuilder.reset(); } - 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; + 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 mCursorAnchorInfoBuilder.build(); } @Override @@ -188,99 +134,112 @@ public Editable getEditable() { @Override public boolean beginBatchEdit() { - mBatchCount++; + mEditable.beginBatchEdit(); return super.beginBatchEdit(); } @Override public boolean endBatchEdit() { boolean result = super.endBatchEdit(); - mBatchCount--; - updateEditingState(); + mEditable.endBatchEdit(); return result; } @Override public boolean commitText(CharSequence text, int newCursorPosition) { - boolean result = super.commitText(text, newCursorPosition); - markDirty(); + final boolean result = super.commitText(text, newCursorPosition); return result; } @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - if (Selection.getSelectionStart(mEditable) == -1) return true; + if (mEditable.getSelectionStart() == -1) { + return true; + } - boolean result = super.deleteSurroundingText(beforeLength, afterLength); - markDirty(); + final boolean result = super.deleteSurroundingText(beforeLength, afterLength); return result; } @Override public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { boolean result = super.deleteSurroundingTextInCodePoints(beforeLength, afterLength); - markDirty(); return result; } @Override public boolean setComposingRegion(int start, int end) { - boolean result = super.setComposingRegion(start, end); - markDirty(); + final boolean result = super.setComposingRegion(start, end); 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); } - markDirty(); + endBatchEdit(); return result; } @Override public boolean 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(); + final boolean result = super.finishComposingText(); return result; } + // When there's not enough vertical screen space, the IME may enter fullscreen mode and this + // method will be used to get (a portion of) the currently edited text. Samsung keyboard seems + // to use this method instead of InputConnection#getText{Before,After}Cursor. + // See https://github.com/flutter/engine/pull/17426. // TODO(garyq): Implement a more feature complete version of getExtractedText @Override public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { - ExtractedText extractedText = new ExtractedText(); - extractedText.selectionStart = Selection.getSelectionStart(mEditable); - extractedText.selectionEnd = Selection.getSelectionEnd(mEditable); - extractedText.text = mEditable.toString(); - return extractedText; + 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; } @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. @@ -304,9 +263,9 @@ private boolean isSamsung() { @Override public boolean setSelection(int start, int end) { + beginBatchEdit(); boolean result = super.setSelection(start, end); - markDirty(); - updateEditingState(); + endBatchEdit(); return result; } @@ -336,7 +295,6 @@ public boolean sendKeyEvent(KeyEvent event) { return true; } - markDirty(); if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { int selStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable); @@ -349,7 +307,6 @@ public boolean sendKeyEvent(KeyEvent event) { // Delete the selection. Selection.setSelection(mEditable, selStart); mEditable.delete(selStart, selEnd); - updateEditingState(); return true; } return false; @@ -440,7 +397,13 @@ public boolean sendKeyEvent(KeyEvent event) { @Override public boolean performContextMenuAction(int id) { - markDirty(); + beginBatchEdit(); + final boolean result = doPerformContextMenuAction(id); + endBatchEdit(); + return result; + } + + private boolean doPerformContextMenuAction(int id) { if (id == android.R.id.selectAll) { setSelection(0, mEditable.length()); return true; @@ -500,7 +463,6 @@ public boolean performPrivateCommand(String action, Bundle data) { @Override public boolean performEditorAction(int actionCode) { - markDirty(); switch (actionCode) { case EditorInfo.IME_ACTION_NONE: textInputChannel.newline(mClient); @@ -530,4 +492,37 @@ 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 new file mode 100644 index 0000000000000..49f09bb996900 --- /dev/null +++ b/shell/platform/android/io/flutter/plugin/editing/ListenableEditingState.java @@ -0,0 +1,247 @@ +// 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 b58cb8c67c399..a68487a3b085e 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -12,7 +12,6 @@ import android.provider.Settings; import android.text.Editable; import android.text.InputType; -import android.text.Selection; import android.util.SparseArray; import android.view.View; import android.view.ViewStructure; @@ -20,7 +19,6 @@ import android.view.autofill.AutofillId; import android.view.autofill.AutofillManager; import android.view.autofill.AutofillValue; -import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; @@ -28,13 +26,17 @@ 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 { +public class TextInputPlugin implements ListenableEditingState.EditingStateWatcher { + private static final String TAG = "TextInputPlugin"; + @NonNull private final View mView; @NonNull private final InputMethodManager mImm; @NonNull private final AutofillManager afm; @@ -42,7 +44,7 @@ public class TextInputPlugin { @NonNull private InputTarget inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); @Nullable private TextInputChannel.Configuration configuration; @Nullable private SparseArray mAutofillConfigurations; - @Nullable private Editable mEditable; + @Nullable private ListenableEditingState mEditable; private boolean mRestartInputPending; @Nullable private InputConnection lastInputConnection; @NonNull private PlatformViewsController platformViewsController; @@ -51,6 +53,9 @@ public class TextInputPlugin { 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 @@ -220,6 +225,10 @@ public void unlockPlatformViewInputConnection() { public void destroy() { platformViewsController.detachTextInputPlugin(); textInputChannel.setTextInputMethodHandler(null); + notifyViewExited(); + if (mEditable != null) { + mEditable.removeEditingStateListener(this); + } if (imeSyncCallback != null) { imeSyncCallback.remove(); } @@ -326,8 +335,8 @@ public InputConnection createInputConnection(View view, EditorInfo outAttrs) { InputConnectionAdaptor connection = new InputConnectionAdaptor( view, inputTarget.id, textInputChannel, keyProcessor, mEditable, outAttrs); - outAttrs.initialSelStart = Selection.getSelectionStart(mEditable); - outAttrs.initialSelEnd = Selection.getSelectionEnd(mEditable); + outAttrs.initialSelStart = mEditable.getSelectionStart(); + outAttrs.initialSelEnd = mEditable.getSelectionEnd(); lastInputConnection = connection; return lastInputConnection; @@ -373,51 +382,26 @@ 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) { @@ -432,43 +416,14 @@ private void setPlatformViewTextInputClient(int platformViewId) { mRestartInputPending = false; } - private void applyStateToSelection(TextInputChannel.TextEditState state) { - int selStart = state.selectionStart; - int selEnd = state.selectionEnd; - if (selStart >= 0 - && selStart <= mEditable.length() - && selEnd >= 0 - && selEnd <= mEditable.length()) { - Selection.setSelection(mEditable, selStart, selEnd); - } else { - Selection.removeSelection(mEditable); - } - } - @VisibleForTesting void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { - // Always replace the contents of mEditable if the text differs - if (!state.text.equals(mEditable.toString())) { - mEditable.replace(0, mEditable.length(), state.text); - } - 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 { + 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) { mImm.restartInput(view); mRestartInputPending = false; } @@ -518,17 +473,200 @@ public void inspect(double x, double y) { (int) Math.ceil(minMax[3] * density)); } - private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration configuration) { + // 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(); - this.configuration = configuration; - final TextInputChannel.Configuration[] configurations = configuration.fields; + 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); + } + } - if (configuration.autofill == null) { + // -------- 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; + } + + this.configuration = configuration; + if (configuration == null || configuration.autofill == null) { // Disables autofill if the configuration doesn't have an autofill field. mAutofillConfigurations = null; return; } + final TextInputChannel.Configuration[] configurations = configuration.fields; mAutofillConfigurations = new SparseArray<>(); if (configurations == null) { @@ -537,19 +675,17 @@ private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration } else { for (TextInputChannel.Configuration config : configurations) { TextInputChannel.Configuration.Autofill autofill = config.autofill; - if (autofill == null) { - continue; + if (autofill != null) { + mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config); + afm.notifyValueChanged( + mView, + autofill.uniqueIdentifier.hashCode(), + AutofillValue.forText(autofill.editState.text)); } - - mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config); } } } - private boolean needsAutofill() { - return mAutofillConfigurations != null; - } - public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !needsAutofill()) { return; @@ -568,13 +704,13 @@ public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags structure.addChildCount(1); final ViewStructure child = structure.newChild(i); child.setAutofillId(parentId, autofillId); - child.setAutofillValue(AutofillValue.forText(autofill.editState.text)); child.setAutofillHints(autofill.hints); child.setAutofillType(View.AUTOFILL_TYPE_TEXT); child.setVisibility(View.VISIBLE); - // Some autofill services expect child structures to be visible. - // Reports the real size of the child if it's the current client. + // For some autofill services, only visible input fields are eligible for autofill. + // Reports the real size of the child if it's the current client, or 1x1 if we don't + // know the real dimensions of the child. if (triggerIdentifier.hashCode() == autofillId && lastClientRect != null) { child.setDimens( lastClientRect.left, @@ -583,9 +719,10 @@ 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)); } } } @@ -612,7 +749,7 @@ public void autofill(SparseArray values) { final TextInputChannel.Configuration.Autofill autofill = config.autofill; final String value = values.valueAt(i).getTextValue().toString(); final TextInputChannel.TextEditState newState = - new TextInputChannel.TextEditState(value, value.length(), value.length()); + new TextInputChannel.TextEditState(value, value.length(), value.length(), -1, -1); // The value of the currently focused text field needs to be updated. if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) { @@ -623,83 +760,5 @@ public void autofill(SparseArray values) { textInputChannel.updateEditingStateWithTag(inputTarget.id, editingValues); } - - // Samsung's Korean keyboard has a bug where it always attempts to combine characters based on - // its internal state, ignoring if and when the cursor is moved programmatically. The same bug - // also causes non-korean keyboards to occasionally duplicate text when tapping in the middle - // of existing text to edit it. - // - // Fully restarting the IMM works around this because it flushes the keyboard's internal state - // and stops it from trying to incorrectly combine characters. However this also has some - // negative performance implications, so we don't want to apply this workaround in every case. - @SuppressLint("NewApi") // New API guard is inline, the linter can't see it. - @SuppressWarnings("deprecation") - private boolean isRestartAlwaysRequired() { - InputMethodSubtype subtype = mImm.getCurrentInputMethodSubtype(); - // Impacted devices all shipped with Android Lollipop or newer. - if (subtype == null - || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP - || !Build.MANUFACTURER.equals("samsung")) { - return false; - } - String keyboardName = - Settings.Secure.getString( - mView.getContext().getContentResolver(), Settings.Secure.DEFAULT_INPUT_METHOD); - // The Samsung keyboard is called "com.sec.android.inputmethod/.SamsungKeypad" but look - // for "Samsung" just in case Samsung changes the name of the keyboard. - return keyboardName.contains("Samsung"); - } - - private void clearTextInputClient() { - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { - // Focus changes in the framework tree have no guarantees on the order focus nodes are - // notified. A node - // that lost focus may be notified before or after a node that gained focus. - // When moving the focus from a Flutter text field to an AndroidView, it is possible that the - // Flutter text - // field's focus node will be notified that it lost focus after the AndroidView was notified - // that it gained - // focus. When this happens the text field will send a clearTextInput command which we ignore. - // By doing this we prevent the framework from clearing a platform view input client(the only - // way to do so - // is to set a new framework text client). I don't see an obvious use case for "clearing" a - // platform views - // text input client, and it may be error prone as we don't know how the platform view manages - // the input - // connection and we probably shouldn't interfere. - // If we ever want to allow the framework to clear a platform view text client we should - // probably consider - // changing the focus manager such that focus nodes that lost focus are notified before focus - // nodes that - // gained focus as part of the same focus event. - return; - } - inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); - unlockPlatformViewInputConnection(); - notifyViewExited(); - lastClientRect = null; - } - - private static class InputTarget { - enum Type { - NO_TARGET, - // InputConnection is managed by the TextInputPlugin, and events are forwarded to the Flutter - // framework. - FRAMEWORK_CLIENT, - // InputConnection is managed by an embedded platform view. - PLATFORM_VIEW - } - - public InputTarget(@NonNull Type type, int id) { - this.type = type; - this.id = id; - } - - @NonNull Type type; - // The ID of the input target. - // - // For framework clients this is the framework input connection client ID. - // For platform views this is the platform view's ID. - int id; - } + // -------- End: Autofill ------- } diff --git a/shell/platform/android/test/README.md b/shell/platform/android/test/README.md index 9817c846587a5..2958b4323f6f4 100644 --- a/shell/platform/android/test/README.md +++ b/shell/platform/android/test/README.md @@ -14,9 +14,10 @@ integration tests in other repos. `shell/platform/android/**test**/io/flutter/util/Preconditions**Test**.java`. 2. Add your file to the `sources` of the `robolectric_tests` build target in `/shell/platform/android/BUILD.gn`. This compiles the test class into the - test jar. -3. Add your class to the `@SuiteClasses` annotation in `FlutterTestSuite.java`. - This makes sure the test is actually executed at run time. + test jar. +3. Import your test class and add it to the `@SuiteClasses` annotation in + `FlutterTestSuite.java`. This makes sure the test is actually executed at + run time. 4. Write your test. 5. Build and run with `testing/run_tests.py [--type=java] [--java-filter=]`. diff --git a/shell/platform/android/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java index f440f736589e1..635b6680475d0 100644 --- a/shell/platform/android/test/io/flutter/FlutterTestSuite.java +++ b/shell/platform/android/test/io/flutter/FlutterTestSuite.java @@ -30,6 +30,7 @@ import io.flutter.plugin.common.StandardMessageCodecTest; import io.flutter.plugin.common.StandardMethodCodecTest; import io.flutter.plugin.editing.InputConnectionAdaptorTest; +import io.flutter.plugin.editing.ListenableEditingStateTest; import io.flutter.plugin.editing.TextInputPluginTest; import io.flutter.plugin.mouse.MouseCursorPluginTest; import io.flutter.plugin.platform.PlatformPluginTest; @@ -70,6 +71,7 @@ FlutterViewTest.class, InputConnectionAdaptorTest.class, KeyEventChannelTest.class, + ListenableEditingStateTest.class, LocalizationPluginTest.class, MouseCursorPluginTest.class, PlatformChannelTest.class, diff --git a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index a2d8eb963a42c..feec49b017e75 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyInt; @@ -14,17 +15,22 @@ 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; @@ -42,9 +48,15 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowClipboardManager; +import org.robolectric.shadows.ShadowInputMethodManager; -@Config(manifest = Config.NONE, shadows = ShadowClipboardManager.class) +@Config( + manifest = Config.NONE, + shadows = {ShadowClipboardManager.class, InputConnectionAdaptorTest.TestImm.class}) @RunWith(RobolectricTestRunner.class) public class InputConnectionAdaptorTest { // Verifies the method and arguments for a captured method call. @@ -70,8 +82,8 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { int inputTargetId = 0; TextInputChannel textInputChannel = new TextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - Editable mEditable = Editable.Factory.getInstance().newEditable(""); - Editable spyEditable = spy(mEditable); + ListenableEditingState mEditable = new ListenableEditingState(null, testView); + ListenableEditingState spyEditable = spy(mEditable); EditorInfo outAttrs = new EditorInfo(); outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; @@ -88,7 +100,7 @@ public void inputConnectionAdaptor_ReceivesEnter() throws NullPointerException { @Test public void testPerformContextMenuAction_selectAll() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); boolean didConsume = adaptor.performContextMenuAction(android.R.id.selectAll); @@ -104,7 +116,7 @@ public void testPerformContextMenuAction_cut() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); int selStart = 6; int selEnd = 11; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); CharSequence textToBeCut = editable.subSequence(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); @@ -122,7 +134,7 @@ public void testPerformContextMenuAction_copy() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); int selStart = 6; int selEnd = 11; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); assertFalse(clipboardManager.hasText()); @@ -142,7 +154,7 @@ public void testPerformContextMenuAction_paste() { RuntimeEnvironment.application.getSystemService(ClipboardManager.class); String textToBePasted = "deadbeef"; clipboardManager.setText(textToBePasted); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); boolean didConsume = adaptor.performContextMenuAction(android.R.id.paste); @@ -159,7 +171,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -187,7 +199,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -221,7 +233,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -253,7 +265,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -288,7 +300,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -320,7 +332,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -356,7 +368,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -390,7 +402,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -422,7 +434,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); - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = new InputConnectionAdaptor( testView, client, textInputChannel, mockKeyProcessor, editable, null, mockFlutterJNI); @@ -452,7 +464,7 @@ public void testPerformPrivateCommand_dataIsFloatArray() throws JSONException { public void testSendKeyEvent_shiftKeyUpCancelsSelection() { int selStart = 5; int selEnd = 10; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftKeyUp = new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SHIFT_LEFT); @@ -466,7 +478,7 @@ public void testSendKeyEvent_shiftKeyUpCancelsSelection() { @Test public void testSendKeyEvent_leftKeyMovesCaretLeft() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -480,7 +492,7 @@ public void testSendKeyEvent_leftKeyMovesCaretLeft() { @Test public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { int selStart = 75; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -623,7 +635,7 @@ public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { int selStart = 5; int selEnd = 40; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent leftKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); @@ -637,7 +649,7 @@ public void testSendKeyEvent_leftKeyExtendsSelectionLeft() { @Test public void testSendKeyEvent_shiftLeftKeyStartsSelectionLeft() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftLeftKeyDown = @@ -653,7 +665,7 @@ public void testSendKeyEvent_shiftLeftKeyStartsSelectionLeft() { @Test public void testSendKeyEvent_rightKeyMovesCaretRight() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -671,7 +683,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexRegion() { // three region indicators, and the final seventh character should be // considered to be on its own because it has no partner. String SAMPLE_REGION_TEXT = "🇷🇷🇷🇷🇷🇷🇷"; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_REGION_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_REGION_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -705,7 +717,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexRegion() { @Test public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { int selStart = 0; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -841,7 +853,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { public void testSendKeyEvent_rightKeyExtendsSelectionRight() { int selStart = 5; int selEnd = 40; - Editable editable = sampleEditable(selStart, selEnd); + ListenableEditingState editable = sampleEditable(selStart, selEnd); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent rightKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); @@ -855,7 +867,7 @@ public void testSendKeyEvent_rightKeyExtendsSelectionRight() { @Test public void testSendKeyEvent_shiftRightKeyStartsSelectionRight() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent shiftRightKeyDown = @@ -871,7 +883,7 @@ public void testSendKeyEvent_shiftRightKeyStartsSelectionRight() { @Test public void testSendKeyEvent_upKeyMovesCaretUp() { int selStart = SAMPLE_TEXT.indexOf('\n') + 4; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent upKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP); @@ -886,7 +898,7 @@ public void testSendKeyEvent_upKeyMovesCaretUp() { @Test public void testSendKeyEvent_downKeyMovesCaretDown() { int selStart = 4; - Editable editable = sampleEditable(selStart, selStart); + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN); @@ -901,7 +913,8 @@ public void testSendKeyEvent_downKeyMovesCaretDown() { @Test public void testMethod_getExtractedText() { int selStart = 5; - Editable editable = sampleEditable(selStart, selStart); + + ListenableEditingState editable = sampleEditable(selStart, selStart); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); ExtractedText extractedText = adaptor.getExtractedText(null, 0); @@ -912,53 +925,113 @@ public void testMethod_getExtractedText() { } @Test - public void inputConnectionAdaptor_RepeatFilter() throws NullPointerException { + public void testExtractedText_monitoring() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + ListenableEditingState editable = sampleEditable(5, 5); View testView = new View(RuntimeEnvironment.application); - FlutterJNI mockFlutterJni = mock(FlutterJNI.class); - DartExecutor dartExecutor = spy(new DartExecutor(mockFlutterJni, mock(AssetManager.class))); - int inputTargetId = 0; - TestTextInputChannel textInputChannel = new TestTextInputChannel(dartExecutor); AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); - Editable mEditable = Editable.Factory.getInstance().newEditable(""); - Editable spyEditable = spy(mEditable); - EditorInfo outAttrs = new EditorInfo(); - outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE; - - InputConnectionAdaptor inputConnectionAdaptor = + InputConnectionAdaptor adaptor = new InputConnectionAdaptor( - testView, inputTargetId, textInputChannel, mockKeyProcessor, spyEditable, outAttrs); + 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; + } - 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); + AndroidKeyProcessor mockKeyProcessor = mock(AndroidKeyProcessor.class); + ListenableEditingState editable = sampleEditable(5, 5); + View testView = new View(RuntimeEnvironment.application); + 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(); + + // Monitoring only. Does not send update immediately. + adaptor.requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR); + assertNull(testImm.lastCursorAnchorInfo); + + // Monitor selection changes. + adaptor.setSelection(0, 1); + CursorAnchorInfo cursorAnchorInfo = testImm.lastCursorAnchorInfo; + assertEquals(0, cursorAnchorInfo.getSelectionStart()); + assertEquals(1, cursorAnchorInfo.getSelectionEnd()); + + // Turn monitoring off. + testImm.resetStates(); + assertNull(testImm.lastCursorAnchorInfo); + adaptor.requestCursorUpdates(InputConnection.CURSOR_UPDATE_IMMEDIATE); + cursorAnchorInfo = testImm.lastCursorAnchorInfo; + assertEquals(0, cursorAnchorInfo.getSelectionStart()); + assertEquals(1, cursorAnchorInfo.getSelectionEnd()); + + // No more updates. + testImm.resetStates(); + adaptor.setSelection(1, 3); + assertNull(testImm.lastCursorAnchorInfo); } @Test public void testSendKeyEvent_delKeyDeletesBackward() { int selStart = 29; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_RTL_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_RTL_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); @@ -979,7 +1052,7 @@ public void testSendKeyEvent_delKeyDeletesBackward() { @Test public void testSendKeyEvent_delKeyDeletesBackwardComplexEmojis() { int selStart = 75; - Editable editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); + ListenableEditingState editable = sampleEditable(selStart, selStart, SAMPLE_EMOJI_TEXT); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); KeyEvent downKeyDown = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); @@ -1120,7 +1193,7 @@ public void testSendKeyEvent_delKeyDeletesBackwardComplexEmojis() { @Test public void testDoesNotConsumeBackButton() { - Editable editable = sampleEditable(0, 0); + ListenableEditingState editable = sampleEditable(0, 0); InputConnectionAdaptor adaptor = sampleInputConnectionAdaptor(editable); FakeKeyEvent keyEvent = new FakeKeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK); @@ -1154,19 +1227,24 @@ public void testDoesNotConsumeBackButton() { private static final String SAMPLE_RTL_TEXT = "متن ساختگی" + "\nبرای تستfor test😊"; - private static Editable sampleEditable(int selStart, int selEnd) { - SpannableStringBuilder sample = new SpannableStringBuilder(SAMPLE_TEXT); + private static ListenableEditingState sampleEditable(int selStart, int selEnd) { + ListenableEditingState sample = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + sample.replace(0, 0, SAMPLE_TEXT); Selection.setSelection(sample, selStart, selEnd); return sample; } - private static Editable sampleEditable(int selStart, int selEnd, String text) { - SpannableStringBuilder sample = new SpannableStringBuilder(text); + private static ListenableEditingState sampleEditable(int selStart, int selEnd, String text) { + ListenableEditingState sample = + new ListenableEditingState(null, new View(RuntimeEnvironment.application)); + sample.replace(0, 0, text); Selection.setSelection(sample, selStart, selEnd); return sample; } - private static InputConnectionAdaptor sampleInputConnectionAdaptor(Editable editable) { + private static InputConnectionAdaptor sampleInputConnectionAdaptor( + ListenableEditingState editable) { View testView = new View(RuntimeEnvironment.application); int client = 0; TextInputChannel textInputChannel = mock(TextInputChannel.class); @@ -1221,4 +1299,61 @@ public void updateEditingState( updateEditingStateInvocations++; } } + + @Implements(InputMethodManager.class) + public static class TestImm extends ShadowInputMethodManager { + public static int empty = -999; + // private InputMethodSubtype currentInputMethodSubtype; + CursorAnchorInfo lastCursorAnchorInfo; + int lastExtractedTextToken = empty; + ExtractedText lastExtractedText; + + int lastSelectionStart = empty; + int lastSelectionEnd = empty; + int lastCandidatesStart = empty; + int lastCandidatesEnd = empty; + + public TestImm() {} + + // @Implementation + // public InputMethodSubtype getCurrentInputMethodSubtype() { + // return currentInputMethodSubtype; + // } + + // public void setCurrentInputMethodSubtype(InputMethodSubtype inputMethodSubtype) { + // this.currentInputMethodSubtype = inputMethodSubtype; + // } + + @Implementation + public void updateCursorAnchorInfo(View view, CursorAnchorInfo cursorAnchorInfo) { + lastCursorAnchorInfo = cursorAnchorInfo; + } + + @Implementation + public void updateExtractedText(View view, int token, ExtractedText text) { + lastExtractedTextToken = token; + lastExtractedText = text; + } + + @Implementation + public void updateSelection( + View view, int selStart, int selEnd, int candidatesStart, int candidatesEnd) { + lastSelectionStart = selStart; + lastSelectionEnd = selEnd; + lastCandidatesStart = candidatesStart; + lastCandidatesEnd = candidatesEnd; + } + + public void resetStates() { + lastExtractedText = null; + lastExtractedTextToken = empty; + + lastSelectionStart = empty; + lastSelectionEnd = empty; + lastCandidatesStart = empty; + lastCandidatesEnd = empty; + + lastCursorAnchorInfo = null; + } + } } diff --git a/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java new file mode 100644 index 0000000000000..d542a27e151bb --- /dev/null +++ b/shell/platform/android/test/io/flutter/plugin/editing/ListenableEditingStateTest.java @@ -0,0 +1,408 @@ +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 7cec8faafb6fd..263a19abd212c 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -3,7 +3,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.AdditionalMatchers.aryEq; -import static org.mockito.AdditionalMatchers.geq; +import static org.mockito.AdditionalMatchers.gt; import static org.mockito.Matchers.anyInt; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; @@ -19,20 +19,26 @@ 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; @@ -60,10 +66,13 @@ import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowAutofillManager; import org.robolectric.shadows.ShadowBuild; import org.robolectric.shadows.ShadowInputMethodManager; -@Config(manifest = Config.NONE, shadows = TextInputPluginTest.TestImm.class) +@Config( + manifest = Config.NONE, + shadows = {TextInputPluginTest.TestImm.class, TextInputPluginTest.TestAfm.class}) @RunWith(RobolectricTestRunner.class) public class TextInputPluginTest { @Mock FlutterJNI mockFlutterJni; @@ -120,6 +129,138 @@ 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. @@ -146,12 +287,12 @@ public void setTextInputEditingState_doesNotRestartWhenTextIsIdentical() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Verify that we haven't restarted the input. assertEquals(1, testImm.getRestartCount(testView)); @@ -185,13 +326,13 @@ public void setTextInputEditingState_alwaysSetEditableWhenDifferent() { // changed text, we should // always set the Editable contents. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("hello", 0, 0)); + testView, new TextInputChannel.TextEditState("hello", 0, 0, -1, -1)); assertEquals(1, testImm.getRestartCount(testView)); assertTrue(textInputPlugin.getEditable().toString().equals("hello")); // No pending restart, set Editable contents anyways. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("Shibuyawoo", 0, 0)); + testView, new TextInputChannel.TextEditState("Shibuyawoo", 0, 0, -1, -1)); assertEquals(1, testImm.getRestartCount(testView)); assertTrue(textInputPlugin.getEditable().toString().equals("Shibuyawoo")); } @@ -232,12 +373,12 @@ public void setTextInputEditingState_alwaysRestartsOnAffectedDevices2() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Verify that we've restarted the input. assertEquals(2, testImm.getRestartCount(testView)); @@ -275,12 +416,12 @@ public void setTextInputEditingState_doesNotRestartOnUnaffectedDevices() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Move the cursor. assertEquals(1, testImm.getRestartCount(testView)); textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Verify that we've restarted the input. assertEquals(1, testImm.getRestartCount(testView)); @@ -311,7 +452,7 @@ public void setTextInputEditingState_nullInputMethodSubtype() { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); assertEquals(1, testImm.getRestartCount(testView)); } @@ -353,7 +494,7 @@ public void inputConnection_createsActionFromEnter() throws JSONException { null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); ArgumentCaptor channelCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor bufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class); @@ -394,6 +535,9 @@ 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); @@ -425,22 +569,24 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( - testView, new TextInputChannel.TextEditState("", 0, 0)); + testView, new TextInputChannel.TextEditState("text", 0, 0, -1, -1)); InputConnection connection = textInputPlugin.createInputConnection(testView, new EditorInfo()); + connection.requestCursorUpdates( + InputConnection.CURSOR_UPDATE_MONITOR | InputConnection.CURSOR_UPDATE_IMMEDIATE); + connection.finishComposingText(); - if (Build.VERSION.SDK_INT >= 21) { - CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder(); - builder.setComposingText(-1, ""); - CursorAnchorInfo anchorInfo = builder.build(); - assertEquals(testImm.getLastCursorAnchorInfo(), anchorInfo); - } + assertEquals(-1, testImm.getLastCursorAnchorInfo().getComposingTextStart()); + assertEquals(0, testImm.getLastCursorAnchorInfo().getComposingText().length()); } + // -------- 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)); @@ -448,10 +594,12 @@ public void autofill_onProvideVirtualViewStructure() { new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); final TextInputChannel.Configuration.Autofill autofill1 = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration.Autofill autofill2 = new TextInputChannel.Configuration.Autofill( - "2", new String[] {"HINT2", "EXTRA"}, new TextInputChannel.TextEditState("", 0, 0)); + "2", + new String[] {"HINT2", "EXTRA"}, + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); final TextInputChannel.Configuration config1 = new TextInputChannel.Configuration( @@ -502,11 +650,11 @@ public void autofill_onProvideVirtualViewStructure() { verify(children[0]).setAutofillId(any(), eq("1".hashCode())); verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); - verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); + verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); verify(children[1]).setAutofillId(any(), eq("2".hashCode())); verify(children[1]).setAutofillHints(aryEq(new String[] {"HINT2", "EXTRA"})); - verify(children[1]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); + verify(children[1]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); } @Test @@ -521,7 +669,7 @@ public void autofill_onProvideVirtualViewStructure_single() { new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); final TextInputChannel.Configuration.Autofill autofill = new TextInputChannel.Configuration.Autofill( - "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); // Autofill should still work without AutofillGroup. textInputPlugin.setTextInputClient( @@ -550,9 +698,195 @@ public void autofill_onProvideVirtualViewStructure_single() { verify(children[0]).setAutofillId(any(), eq("1".hashCode())); verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); // Verifies that the child has a non-zero size. - verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), geq(0), geq(0)); + verify(children[0]).setDimens(anyInt(), anyInt(), anyInt(), anyInt(), gt(0), gt(0)); + } + + @Test + public void autofill_testLifeCycle() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + TestAfm testAfm = + Shadow.extract(RuntimeEnvironment.application.getSystemService(AutofillManager.class)); + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputChannel textInputChannel = new TextInputChannel(mock(DartExecutor.class)); + TextInputPlugin textInputPlugin = + new TextInputPlugin(testView, textInputChannel, mock(PlatformViewsController.class)); + + // Set up an autofill scenario with 2 fields. + final TextInputChannel.Configuration.Autofill autofill1 = + new TextInputChannel.Configuration.Autofill( + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + final TextInputChannel.Configuration.Autofill autofill2 = + new TextInputChannel.Configuration.Autofill( + "2", + new String[] {"HINT2", "EXTRA"}, + new TextInputChannel.TextEditState("", 0, 0, -1, -1)); + + final TextInputChannel.Configuration config1 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + null); + final TextInputChannel.Configuration config2 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill2, + null); + + // Set client. This should call notifyViewExited on the FlutterView if the previous client is + // also eligible for autofill. + final TextInputChannel.Configuration autofillConfiguration = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + new TextInputChannel.Configuration[] {config1, config2}); + + textInputPlugin.setTextInputClient(0, autofillConfiguration); + + // notifyViewExited should not be called as this is the first client we set. + assertEquals(testAfm.empty, testAfm.exitId); + + // The framework updates the text, call notifyValueChanged. + textInputPlugin.setTextInputEditingState( + testView, new TextInputChannel.TextEditState("new text", -1, -1, -1, -1)); + assertEquals("new text", testAfm.changeString); + assertEquals("1".hashCode(), testAfm.changeVirtualId); + + // The input method updates the text, call notifyValueChanged. + testAfm.resetStates(); + 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); } + @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 = @@ -813,4 +1147,49 @@ 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; + } + } }