blob: d1b58039fb9104bed0f90eedc8d160173467a049 [file] [log] [blame]
// 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.annotation.SuppressLint;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.os.Build;
import android.provider.Settings;
import android.text.DynamicLayout;
import android.text.Editable;
import android.text.InputType;
import android.text.Layout;
import android.text.Selection;
import android.text.TextPaint;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputMethodManager;
import android.view.inputmethod.InputMethodSubtype;
import io.flutter.Log;
import io.flutter.embedding.engine.systemchannels.TextInputChannel;
class InputConnectionAdaptor extends BaseInputConnection {
private final View mFlutterView;
private final int mClient;
private final TextInputChannel textInputChannel;
private final Editable mEditable;
private final EditorInfo mEditorInfo;
private int mBatchCount;
private InputMethodManager mImm;
private final Layout mLayout;
// 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(
View view,
int client,
TextInputChannel textInputChannel,
Editable editable,
EditorInfo editorInfo) {
super(view, true);
mFlutterView = view;
mClient = client;
this.textInputChannel = textInputChannel;
mEditable = editable;
mEditorInfo = editorInfo;
mBatchCount = 0;
// We create a dummy Layout with max width so that the selection
// shifting acts as if all text were in one line.
mLayout =
new DynamicLayout(
mEditable,
new TextPaint(),
Integer.MAX_VALUE,
Layout.Alignment.ALIGN_NORMAL,
1.0f,
0.0f,
false);
mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
isSamsung = isSamsung();
}
// 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;
}
// This should be called whenever a change could have been made to
// the value of mEditable, which will make any call of updateEditingState()
// ineligible for repeat checking as we do not want to skip sending real changes
// to the framework.
public void markDirty() {
// Disable updateEditngState's repeat-update check
mRepeatCheckNeeded = false;
}
@Override
public Editable getEditable() {
return mEditable;
}
@Override
public boolean beginBatchEdit() {
mBatchCount++;
return super.beginBatchEdit();
}
@Override
public boolean endBatchEdit() {
boolean result = super.endBatchEdit();
mBatchCount--;
updateEditingState();
return result;
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
boolean result = super.commitText(text, newCursorPosition);
markDirty();
return result;
}
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
if (Selection.getSelectionStart(mEditable) == -1) return true;
boolean result = super.deleteSurroundingText(beforeLength, afterLength);
markDirty();
return result;
}
@Override
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
boolean result = super.deleteSurroundingTextInCodePoints(beforeLength, afterLength);
markDirty();
return result;
}
@Override
public boolean setComposingRegion(int start, int end) {
boolean result = super.setComposingRegion(start, end);
markDirty();
return result;
}
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
boolean result;
if (text.length() == 0) {
result = super.commitText(text, newCursorPosition);
} else {
result = super.setComposingText(text, newCursorPosition);
}
markDirty();
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();
return result;
}
// 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;
}
@Override
public boolean clearMetaKeyStates(int states) {
boolean result = super.clearMetaKeyStates(states);
markDirty();
return result;
}
// 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.
@SuppressLint("NewApi") // New API guard is inline, the linter can't see it.
@SuppressWarnings("deprecation")
private boolean isSamsung() {
InputMethodSubtype subtype = mImm.getCurrentInputMethodSubtype();
// Impacted devices all shipped with Android Lollipop or newer.
if (subtype == null
|| Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
|| !Build.MANUFACTURER.equals("samsung")) {
return false;
}
String keyboardName =
Settings.Secure.getString(
mFlutterView.getContext().getContentResolver(), Settings.Secure.DEFAULT_INPUT_METHOD);
// The Samsung keyboard is called "com.sec.android.inputmethod/.SamsungKeypad" but look
// for "Samsung" just in case Samsung changes the name of the keyboard.
return keyboardName.contains("Samsung");
}
@Override
public boolean setSelection(int start, int end) {
boolean result = super.setSelection(start, end);
markDirty();
updateEditingState();
return result;
}
// Sanitizes the index to ensure the index is within the range of the
// contents of editable.
private static int clampIndexToEditable(int index, Editable editable) {
int clamped = Math.max(0, Math.min(editable.length(), index));
if (clamped != index) {
Log.d(
"flutter",
"Text selection index was clamped ("
+ index
+ "->"
+ clamped
+ ") to remain in bounds. This may not be your fault, as some keyboards may select outside of bounds.");
}
return clamped;
}
@Override
public boolean sendKeyEvent(KeyEvent event) {
markDirty();
if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
int selStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable);
int selEnd = clampIndexToEditable(Selection.getSelectionEnd(mEditable), mEditable);
if (selEnd > selStart) {
// Delete the selection.
Selection.setSelection(mEditable, selStart);
mEditable.delete(selStart, selEnd);
updateEditingState();
return true;
} else if (selStart > 0) {
// Delete to the left/right of the cursor depending on direction of text.
// TODO(garyq): Explore how to obtain per-character direction. The
// isRTLCharAt() call below is returning blanket direction assumption
// based on the first character in the line.
boolean isRtl = mLayout.isRtlCharAt(mLayout.getLineForOffset(selStart));
try {
if (isRtl) {
Selection.extendRight(mEditable, mLayout);
} else {
Selection.extendLeft(mEditable, mLayout);
}
} catch (IndexOutOfBoundsException e) {
// On some Chinese devices (primarily Huawei, some Xiaomi),
// on initial app startup before focus is lost, the
// Selection.extendLeft and extendRight calls always extend
// from the index of the initial contents of mEditable. This
// try-catch will prevent crashing on Huawei devices by falling
// back to a simple way of deletion, although this a hack and
// will not handle emojis.
Selection.setSelection(mEditable, selStart, selStart - 1);
}
int newStart = clampIndexToEditable(Selection.getSelectionStart(mEditable), mEditable);
int newEnd = clampIndexToEditable(Selection.getSelectionEnd(mEditable), mEditable);
Selection.setSelection(mEditable, Math.min(newStart, newEnd));
// Min/Max the values since RTL selections will start at a higher
// index than they end at.
mEditable.delete(Math.min(newStart, newEnd), Math.max(newStart, newEnd));
updateEditingState();
return true;
}
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) {
int selStart = Selection.getSelectionStart(mEditable);
int selEnd = Selection.getSelectionEnd(mEditable);
if (selStart == selEnd && !event.isShiftPressed()) {
int newSel = Math.max(selStart - 1, 0);
setSelection(newSel, newSel);
} else {
int newSelEnd = Math.max(selEnd - 1, 0);
setSelection(selStart, newSelEnd);
}
return true;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) {
int selStart = Selection.getSelectionStart(mEditable);
int selEnd = Selection.getSelectionEnd(mEditable);
if (selStart == selEnd && !event.isShiftPressed()) {
int newSel = Math.min(selStart + 1, mEditable.length());
setSelection(newSel, newSel);
} else {
int newSelEnd = Math.min(selEnd + 1, mEditable.length());
setSelection(selStart, newSelEnd);
}
return true;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_UP) {
int selStart = Selection.getSelectionStart(mEditable);
int selEnd = Selection.getSelectionEnd(mEditable);
if (selStart == selEnd && !event.isShiftPressed()) {
Selection.moveUp(mEditable, mLayout);
int newSelStart = Selection.getSelectionStart(mEditable);
setSelection(newSelStart, newSelStart);
} else {
Selection.extendUp(mEditable, mLayout);
int newSelStart = Selection.getSelectionStart(mEditable);
int newSelEnd = Selection.getSelectionEnd(mEditable);
setSelection(newSelStart, newSelEnd);
}
return true;
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN) {
int selStart = Selection.getSelectionStart(mEditable);
int selEnd = Selection.getSelectionEnd(mEditable);
if (selStart == selEnd && !event.isShiftPressed()) {
Selection.moveDown(mEditable, mLayout);
int newSelStart = Selection.getSelectionStart(mEditable);
setSelection(newSelStart, newSelStart);
} else {
Selection.extendDown(mEditable, mLayout);
int newSelStart = Selection.getSelectionStart(mEditable);
int newSelEnd = Selection.getSelectionEnd(mEditable);
setSelection(newSelStart, newSelEnd);
}
return true;
// When the enter key is pressed on a non-multiline field, consider it a
// submit instead of a newline.
} else if ((event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|| event.getKeyCode() == KeyEvent.KEYCODE_NUMPAD_ENTER)
&& (InputType.TYPE_TEXT_FLAG_MULTI_LINE & mEditorInfo.inputType) == 0) {
performEditorAction(mEditorInfo.imeOptions & EditorInfo.IME_MASK_ACTION);
return true;
} else {
// Enter a character.
int character = event.getUnicodeChar();
if (character != 0) {
int selStart = Math.max(0, Selection.getSelectionStart(mEditable));
int selEnd = Math.max(0, Selection.getSelectionEnd(mEditable));
int selMin = Math.min(selStart, selEnd);
int selMax = Math.max(selStart, selEnd);
if (selMin != selMax) mEditable.delete(selMin, selMax);
mEditable.insert(selMin, String.valueOf((char) character));
setSelection(selMin + 1, selMin + 1);
}
return true;
}
}
if (event.getAction() == KeyEvent.ACTION_UP
&& (event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_LEFT
|| event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT)) {
int selEnd = Selection.getSelectionEnd(mEditable);
setSelection(selEnd, selEnd);
return true;
}
return false;
}
@Override
public boolean performContextMenuAction(int id) {
markDirty();
if (id == android.R.id.selectAll) {
setSelection(0, mEditable.length());
return true;
} else if (id == android.R.id.cut) {
int selStart = Selection.getSelectionStart(mEditable);
int selEnd = Selection.getSelectionEnd(mEditable);
if (selStart != selEnd) {
int selMin = Math.min(selStart, selEnd);
int selMax = Math.max(selStart, selEnd);
CharSequence textToCut = mEditable.subSequence(selMin, selMax);
ClipboardManager clipboard =
(ClipboardManager)
mFlutterView.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("text label?", textToCut);
clipboard.setPrimaryClip(clip);
mEditable.delete(selMin, selMax);
setSelection(selMin, selMin);
}
return true;
} else if (id == android.R.id.copy) {
int selStart = Selection.getSelectionStart(mEditable);
int selEnd = Selection.getSelectionEnd(mEditable);
if (selStart != selEnd) {
CharSequence textToCopy =
mEditable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd));
ClipboardManager clipboard =
(ClipboardManager)
mFlutterView.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
clipboard.setPrimaryClip(ClipData.newPlainText("text label?", textToCopy));
}
return true;
} else if (id == android.R.id.paste) {
ClipboardManager clipboard =
(ClipboardManager) mFlutterView.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = clipboard.getPrimaryClip();
if (clip != null) {
CharSequence textToPaste = clip.getItemAt(0).coerceToText(mFlutterView.getContext());
int selStart = Math.max(0, Selection.getSelectionStart(mEditable));
int selEnd = Math.max(0, Selection.getSelectionEnd(mEditable));
int selMin = Math.min(selStart, selEnd);
int selMax = Math.max(selStart, selEnd);
if (selMin != selMax) mEditable.delete(selMin, selMax);
mEditable.insert(selMin, textToPaste);
int newSelStart = selMin + textToPaste.length();
setSelection(newSelStart, newSelStart);
}
return true;
}
return false;
}
@Override
public boolean performEditorAction(int actionCode) {
markDirty();
switch (actionCode) {
case EditorInfo.IME_ACTION_NONE:
textInputChannel.newline(mClient);
break;
case EditorInfo.IME_ACTION_UNSPECIFIED:
textInputChannel.unspecifiedAction(mClient);
break;
case EditorInfo.IME_ACTION_GO:
textInputChannel.go(mClient);
break;
case EditorInfo.IME_ACTION_SEARCH:
textInputChannel.search(mClient);
break;
case EditorInfo.IME_ACTION_SEND:
textInputChannel.send(mClient);
break;
case EditorInfo.IME_ACTION_NEXT:
textInputChannel.next(mClient);
break;
case EditorInfo.IME_ACTION_PREVIOUS:
textInputChannel.previous(mClient);
break;
default:
case EditorInfo.IME_ACTION_DONE:
textInputChannel.done(mClient);
break;
}
return true;
}
}