[Android Text Input] Make the editing state listenable and allow batch edits (flutter/engine#21534)

This commit is contained in:
LongCatIsLooong 2020-11-10 19:19:03 -08:00 committed by GitHub
parent 8adeabb04e
commit 7d20e41baf
11 changed files with 1701 additions and 429 deletions

View File

@ -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

View File

@ -217,6 +217,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",
@ -473,6 +474,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",

View File

@ -675,17 +675,60 @@ public class TextInputChannel {
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;
}
}
}

View File

@ -31,68 +31,23 @@ import io.flutter.embedding.android.AndroidKeyProcessor;
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 @@ class InputConnectionAdaptor extends BaseInputConnection {
int client,
TextInputChannel textInputChannel,
AndroidKeyProcessor keyProcessor,
Editable editable,
ListenableEditingState editable,
EditorInfo editorInfo,
FlutterJNI flutterJNI) {
super(view, true);
@ -108,8 +63,8 @@ class InputConnectionAdaptor extends BaseInputConnection {
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 @@ class InputConnectionAdaptor extends BaseInputConnection {
0.0f,
false);
mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
isSamsung = isSamsung();
}
public InputConnectionAdaptor(
@ -133,52 +86,45 @@ class InputConnectionAdaptor extends BaseInputConnection {
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);
// Return if this data has already been sent and no meaningful changes have
// occurred to mark this as dirty. This prevents duplicate remote updates of
// the same data, which can break formatters that change the length of the
// contents.
if (mRepeatCheckNeeded && currentValue.equals(mLastSentTextEditngValue)) {
return;
}
mImm.updateSelection(
mFlutterView,
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;
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;
}
// 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;
private CursorAnchorInfo getCursorAnchorInfo() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return null;
}
if (mCursorAnchorInfoBuilder == null) {
mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder();
} else {
mCursorAnchorInfoBuilder.reset();
}
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 @@ class InputConnectionAdaptor extends BaseInputConnection {
@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 @@ class InputConnectionAdaptor extends BaseInputConnection {
@Override
public boolean setSelection(int start, int end) {
beginBatchEdit();
boolean result = super.setSelection(start, end);
markDirty();
updateEditingState();
endBatchEdit();
return result;
}
@ -336,7 +295,6 @@ class InputConnectionAdaptor extends BaseInputConnection {
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 @@ class InputConnectionAdaptor extends BaseInputConnection {
// Delete the selection.
Selection.setSelection(mEditable, selStart);
mEditable.delete(selStart, selEnd);
updateEditingState();
return true;
}
return false;
@ -440,7 +397,13 @@ class InputConnectionAdaptor extends BaseInputConnection {
@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 @@ class InputConnectionAdaptor extends BaseInputConnection {
@Override
public boolean performEditorAction(int actionCode) {
markDirty();
switch (actionCode) {
case EditorInfo.IME_ACTION_NONE:
textInputChannel.newline(mClient);
@ -530,4 +492,37 @@ class InputConnectionAdaptor extends BaseInputConnection {
}
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 -------
}

View File

@ -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<EditingStateWatcher> mListeners = new ArrayList<>();
private ArrayList<EditingStateWatcher> 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());
}
}

View File

@ -12,7 +12,6 @@ import android.os.Bundle;
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.WindowInsets;
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 android.view.inputmethod.InputMethodSubtype;
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<TextInputChannel.Configuration> 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 class TextInputPlugin {
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 class TextInputPlugin {
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 @@ public class TextInputPlugin {
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 @@ public class TextInputPlugin {
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,112 +473,6 @@ public class TextInputPlugin {
(int) Math.ceil(minMax[3] * density));
}
private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration configuration) {
notifyViewExited();
this.configuration = configuration;
final TextInputChannel.Configuration[] configurations = configuration.fields;
if (configuration.autofill == null) {
// Disables autofill if the configuration doesn't have an autofill field.
mAutofillConfigurations = null;
return;
}
mAutofillConfigurations = new SparseArray<>();
if (configurations == null) {
mAutofillConfigurations.put(
configuration.autofill.uniqueIdentifier.hashCode(), configuration);
} else {
for (TextInputChannel.Configuration config : configurations) {
TextInputChannel.Configuration.Autofill autofill = config.autofill;
if (autofill == null) {
continue;
}
mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config);
}
}
}
private boolean needsAutofill() {
return mAutofillConfigurations != null;
}
public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !needsAutofill()) {
return;
}
final String triggerIdentifier = configuration.autofill.uniqueIdentifier;
final AutofillId parentId = structure.getAutofillId();
for (int i = 0; i < mAutofillConfigurations.size(); i++) {
final int autofillId = mAutofillConfigurations.keyAt(i);
final TextInputChannel.Configuration config = mAutofillConfigurations.valueAt(i);
final TextInputChannel.Configuration.Autofill autofill = config.autofill;
if (autofill == null) {
continue;
}
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.
if (triggerIdentifier.hashCode() == autofillId && lastClientRect != null) {
child.setDimens(
lastClientRect.left,
lastClientRect.top,
0,
0,
lastClientRect.width(),
lastClientRect.height());
} else {
// Reports a fake dimension that's still visible.
child.setDimens(0, 0, 0, 0, 1, 1);
}
}
}
public void autofill(SparseArray<AutofillValue> values) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
final TextInputChannel.Configuration.Autofill currentAutofill = configuration.autofill;
if (currentAutofill == null) {
return;
}
final HashMap<String, TextInputChannel.TextEditState> editingValues = new HashMap<>();
for (int i = 0; i < values.size(); i++) {
int virtualId = values.keyAt(i);
final TextInputChannel.Configuration config = mAutofillConfigurations.get(virtualId);
if (config == null || config.autofill == null) {
continue;
}
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());
// The value of the currently focused text field needs to be updated.
if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) {
setTextInputEditingState(mView, newState);
}
editingValues.put(autofill.uniqueIdentifier, newState);
}
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
@ -650,7 +499,8 @@ public class TextInputPlugin {
return keyboardName.contains("Samsung");
}
private void clearTextInputClient() {
@VisibleForTesting
void clearTextInputClient() {
if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) {
// Focus changes in the framework tree have no guarantees on the order focus nodes are
// notified. A node
@ -660,10 +510,10 @@ public class TextInputPlugin {
// field's focus node will be notified that it lost focus after the AndroidView was notified
// that it gained
// focus. When this happens the text field will send a clearTextInput command which we ignore.
// By doing this we prevent the framework from clearing a platform view input client(the only
// By doing this we prevent the framework from clearing a platform view input client (the only
// way to do so
// is to set a new framework text client). I don't see an obvious use case for "clearing" a
// platform views
// platform view's
// text input client, and it may be error prone as we don't know how the platform view manages
// the input
// connection and we probably shouldn't interfere.
@ -674,9 +524,11 @@ public class TextInputPlugin {
// gained focus as part of the same focus event.
return;
}
mEditable.removeEditingStateListener(this);
notifyViewExited();
updateAutofillConfigurationIfNeeded(null);
inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0);
unlockPlatformViewInputConnection();
notifyViewExited();
lastClientRect = null;
}
@ -702,4 +554,211 @@ public class TextInputPlugin {
// For platform views this is the platform view's ID.
int id;
}
// -------- Start: ListenableEditingState watcher implementation -------
@Override
public void didChangeEditingState(
boolean textChanged, boolean selectionChanged, boolean composingRegionChanged) {
if (textChanged) {
// Notify the autofill manager of the value change.
notifyValueChanged(mEditable.toString());
}
final int selectionStart = mEditable.getSelectionStart();
final int selectionEnd = mEditable.getSelectionEnd();
final int composingStart = mEditable.getComposingStart();
final int composingEnd = mEditable.getComposingEnd();
// Framework needs to sent value first.
final boolean skipFrameworkUpdate =
mLastKnownFrameworkTextEditingState == null
|| (mEditable.toString().equals(mLastKnownFrameworkTextEditingState.text)
&& selectionStart == mLastKnownFrameworkTextEditingState.selectionStart
&& selectionEnd == mLastKnownFrameworkTextEditingState.selectionEnd
&& composingStart == mLastKnownFrameworkTextEditingState.composingStart
&& composingEnd == mLastKnownFrameworkTextEditingState.composingEnd);
// Skip if we're currently setting
if (!skipFrameworkUpdate) {
Log.v(TAG, "send EditingState to flutter: " + mEditable.toString());
textInputChannel.updateEditingState(
inputTarget.id,
mEditable.toString(),
selectionStart,
selectionEnd,
composingStart,
composingEnd);
mLastKnownFrameworkTextEditingState =
new TextEditState(
mEditable.toString(), selectionStart, selectionEnd, composingStart, composingEnd);
}
}
// -------- End: ListenableEditingState watcher implementation -------
// -------- Start: Autofill -------
// ### Setup and provide the initial text values and hints.
//
// The TextInputConfiguration used to setup the current client is also used for populating
// "AutofillVirtualStructure" when requested by the autofill manager (AFM), See
// #onProvideAutofillVirtualStructure.
//
// ### Keep the AFM updated
//
// The autofill session connected to The AFM keeps a copy of the current state for each reported
// field in "AutofillVirtualStructure" (instead of holding a reference to those fields), so the
// AFM needs to be notified when text changes if the client was part of the
// "AutofillVirtualStructure" previously reported to the AFM. This step is essential for
// triggering autofill save. This is done in #didChangeEditingState by calling
// #notifyValueChanged.
//
// Additionally when the text input plugin receives a new TextInputConfiguration,
// AutofillManager#notifyValueChanged will be called on all the autofillable fields contained in
// the TextInputConfiguration, in case some of them are tracked by the session and their values
// have changed. However if the value of an unfocused EditableText is changed in the framework,
// such change will not be sent to the text input plugin until the next TextInput.attach call.
private boolean needsAutofill() {
return mAutofillConfigurations != null;
}
private void notifyViewEntered() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) {
return;
}
final String triggerIdentifier = configuration.autofill.uniqueIdentifier;
final int[] offset = new int[2];
mView.getLocationOnScreen(offset);
Rect rect = new Rect(lastClientRect);
rect.offset(offset[0], offset[1]);
afm.notifyViewEntered(mView, triggerIdentifier.hashCode(), rect);
}
private void notifyViewExited() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|| afm == null
|| configuration == null
|| configuration.autofill == null) {
return;
}
final String triggerIdentifier = configuration.autofill.uniqueIdentifier;
afm.notifyViewExited(mView, triggerIdentifier.hashCode());
}
private void notifyValueChanged(String newValue) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) {
return;
}
final String triggerIdentifier = configuration.autofill.uniqueIdentifier;
afm.notifyValueChanged(mView, triggerIdentifier.hashCode(), AutofillValue.forText(newValue));
}
private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration configuration) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
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) {
mAutofillConfigurations.put(
configuration.autofill.uniqueIdentifier.hashCode(), configuration);
} else {
for (TextInputChannel.Configuration config : configurations) {
TextInputChannel.Configuration.Autofill autofill = config.autofill;
if (autofill != null) {
mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config);
afm.notifyValueChanged(
mView,
autofill.uniqueIdentifier.hashCode(),
AutofillValue.forText(autofill.editState.text));
}
}
}
}
public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !needsAutofill()) {
return;
}
final String triggerIdentifier = configuration.autofill.uniqueIdentifier;
final AutofillId parentId = structure.getAutofillId();
for (int i = 0; i < mAutofillConfigurations.size(); i++) {
final int autofillId = mAutofillConfigurations.keyAt(i);
final TextInputChannel.Configuration config = mAutofillConfigurations.valueAt(i);
final TextInputChannel.Configuration.Autofill autofill = config.autofill;
if (autofill == null) {
continue;
}
structure.addChildCount(1);
final ViewStructure child = structure.newChild(i);
child.setAutofillId(parentId, autofillId);
child.setAutofillHints(autofill.hints);
child.setAutofillType(View.AUTOFILL_TYPE_TEXT);
child.setVisibility(View.VISIBLE);
// For some autofill services, only visible input fields are eligible for autofill.
// Reports the real size of the child if it's the current client, or 1x1 if we don't
// know the real dimensions of the child.
if (triggerIdentifier.hashCode() == autofillId && lastClientRect != null) {
child.setDimens(
lastClientRect.left,
lastClientRect.top,
0,
0,
lastClientRect.width(),
lastClientRect.height());
child.setAutofillValue(AutofillValue.forText(mEditable));
} else {
child.setDimens(0, 0, 0, 0, 1, 1);
child.setAutofillValue(AutofillValue.forText(autofill.editState.text));
}
}
}
public void autofill(SparseArray<AutofillValue> values) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
final TextInputChannel.Configuration.Autofill currentAutofill = configuration.autofill;
if (currentAutofill == null) {
return;
}
final HashMap<String, TextInputChannel.TextEditState> editingValues = new HashMap<>();
for (int i = 0; i < values.size(); i++) {
int virtualId = values.keyAt(i);
final TextInputChannel.Configuration config = mAutofillConfigurations.get(virtualId);
if (config == null || config.autofill == null) {
continue;
}
final TextInputChannel.Configuration.Autofill autofill = config.autofill;
final String value = values.valueAt(i).getTextValue().toString();
final TextInputChannel.TextEditState newState =
new TextInputChannel.TextEditState(value, value.length(), value.length(), -1, -1);
// The value of the currently focused text field needs to be updated.
if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) {
setTextInputEditingState(mView, newState);
}
editingValues.put(autofill.uniqueIdentifier, newState);
}
textInputChannel.updateEditingStateWithTag(inputTarget.id, editingValues);
}
// -------- End: Autofill -------
}

View File

@ -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=<test_class_name>]`.

View File

@ -30,6 +30,7 @@ import io.flutter.external.FlutterLaunchTests;
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 @@ import test.io.flutter.embedding.engine.PluginComponentTest;
FlutterViewTest.class,
InputConnectionAdaptorTest.class,
KeyEventChannelTest.class,
ListenableEditingStateTest.class,
LocalizationPluginTest.class,
MouseCursorPluginTest.class,
PlatformChannelTest.class,

View File

@ -2,6 +2,7 @@ package io.flutter.plugin.editing;
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.verify;
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.mockito.ArgumentCaptor;
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
// 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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
}
@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));
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.");
testImm.resetStates();
inputConnectionAdaptor.beginBatchEdit();
assertEquals(textInputChannel.updateEditingStateInvocations, 1);
inputConnectionAdaptor.endBatchEdit();
assertEquals(textInputChannel.updateEditingStateInvocations, 1);
ExtractedTextRequest request = new ExtractedTextRequest();
request.token = 123;
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);
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;
}
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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
@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 class InputConnectionAdaptorTest {
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 class InputConnectionAdaptorTest {
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;
}
}
}

View File

@ -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<String> 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;
}
}
}

View File

@ -3,7 +3,7 @@ package io.flutter.plugin.editing;
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.annotation.TargetApi;
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.Config;
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 class TextInputPluginTest {
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 class TextInputPluginTest {
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 class TextInputPluginTest {
// 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 class TextInputPluginTest {
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 class TextInputPluginTest {
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 class TextInputPluginTest {
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 class TextInputPluginTest {
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<String> channelCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ByteBuffer> bufferCaptor = ArgumentCaptor.forClass(ByteBuffer.class);
@ -394,6 +535,9 @@ public class TextInputPluginTest {
@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 class TextInputPluginTest {
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 class TextInputPluginTest {
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 class TextInputPluginTest {
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 class TextInputPluginTest {
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 class TextInputPluginTest {
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<BinaryMessenger.BinaryMessageHandler> binaryMessageHandlerCaptor =
@ -813,4 +1147,49 @@ public class TextInputPluginTest {
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;
}
}
}