mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[Android Text Input] Make the editing state listenable and allow batch edits (flutter/engine#21534)
This commit is contained in:
parent
8adeabb04e
commit
7d20e41baf
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 -------
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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 -------
|
||||
}
|
||||
|
||||
@ -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>]`.
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user