未验证 提交 81f219c5 编写于 作者: L LongCatIsLooong 提交者: GitHub

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

上级 3de24322
......@@ -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);
private ExtractedText getExtractedText(ExtractedTextRequest request) {
mExtractedText.startOffset = 0;
mExtractedText.partialStartOffset = -1;
mExtractedText.partialEndOffset = -1;
mExtractedText.selectionStart = mEditable.getSelectionStart();
mExtractedText.selectionEnd = mEditable.getSelectionEnd();
mExtractedText.text =
request == null || (request.flags & GET_TEXT_WITH_STYLES) == 0
? mEditable.toString()
: mEditable;
return mExtractedText;
}
// Return if this data has already been sent and no meaningful changes have
// occurred to mark this as dirty. This prevents duplicate remote updates of
// the same data, which can break formatters that change the length of the
// contents.
if (mRepeatCheckNeeded && currentValue.equals(mLastSentTextEditngValue)) {
return;
private CursorAnchorInfo getCursorAnchorInfo() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return null;
}
if (mCursorAnchorInfoBuilder == null) {
mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder();
} else {
mCursorAnchorInfoBuilder.reset();
}
mImm.updateSelection(
mFlutterView,
currentValue.selectionStart,
currentValue.selectionEnd,
currentValue.composingStart,
currentValue.composingEnd);
textInputChannel.updateEditingState(
mClient,
currentValue.text,
currentValue.selectionStart,
currentValue.selectionEnd,
currentValue.composingStart,
currentValue.composingEnd);
mRepeatCheckNeeded = true;
mLastSentTextEditngValue = currentValue;
}
// This should be called whenever a change could have been made to
// the value of mEditable, which will make any call of updateEditingState()
// ineligible for repeat checking as we do not want to skip sending real changes
// to the framework.
public void markDirty() {
// Disable updateEditngState's repeat-update check
mRepeatCheckNeeded = false;
mCursorAnchorInfoBuilder.setSelectionRange(
mEditable.getSelectionStart(), mEditable.getSelectionEnd());
final int composingStart = mEditable.getComposingStart();
final int composingEnd = mEditable.getComposingEnd();
if (composingStart >= 0 && composingEnd > composingStart) {
mCursorAnchorInfoBuilder.setComposingText(
composingStart, mEditable.toString().subSequence(composingStart, composingEnd));
} else {
mCursorAnchorInfoBuilder.setComposingText(-1, "");
}
return mCursorAnchorInfoBuilder.build();
}
@Override
......@@ -188,99 +134,112 @@ 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 -------
}
// 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());
}
}
......@@ -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,
......
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;
}
}
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册