| // 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.TargetApi; |
| import android.content.ClipData; |
| import android.content.ClipboardManager; |
| import android.content.Context; |
| import android.net.Uri; |
| import android.os.Build; |
| import android.os.Bundle; |
| 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.InputContentInfo; |
| import android.view.inputmethod.InputMethodManager; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.RequiresApi; |
| import androidx.core.view.inputmethod.InputConnectionCompat; |
| import io.flutter.Log; |
| import io.flutter.embedding.engine.FlutterJNI; |
| import io.flutter.embedding.engine.systemchannels.TextInputChannel; |
| import java.io.ByteArrayOutputStream; |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.util.HashMap; |
| import java.util.Map; |
| |
| public class InputConnectionAdaptor extends BaseInputConnection |
| implements ListenableEditingState.EditingStateWatcher { |
| private static final String TAG = "InputConnectionAdaptor"; |
| |
| public interface KeyboardDelegate { |
| public boolean handleEvent(@NonNull KeyEvent keyEvent); |
| } |
| |
| private final View mFlutterView; |
| private final int mClient; |
| private final TextInputChannel textInputChannel; |
| private final ListenableEditingState mEditable; |
| private final EditorInfo mEditorInfo; |
| 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; |
| private final KeyboardDelegate keyboardDelegate; |
| private int batchEditNestDepth = 0; |
| |
| @SuppressWarnings("deprecation") |
| public InputConnectionAdaptor( |
| View view, |
| int client, |
| TextInputChannel textInputChannel, |
| KeyboardDelegate keyboardDelegate, |
| ListenableEditingState editable, |
| EditorInfo editorInfo, |
| FlutterJNI flutterJNI) { |
| super(view, true); |
| mFlutterView = view; |
| mClient = client; |
| this.textInputChannel = textInputChannel; |
| mEditable = editable; |
| mEditable.addEditingStateListener(this); |
| mEditorInfo = editorInfo; |
| this.keyboardDelegate = keyboardDelegate; |
| this.flutterTextUtils = new FlutterTextUtils(flutterJNI); |
| // 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); |
| } |
| |
| public InputConnectionAdaptor( |
| View view, |
| int client, |
| TextInputChannel textInputChannel, |
| KeyboardDelegate keyboardDelegate, |
| ListenableEditingState editable, |
| EditorInfo editorInfo) { |
| this(view, client, textInputChannel, keyboardDelegate, editable, editorInfo, new FlutterJNI()); |
| } |
| |
| 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; |
| } |
| |
| 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 |
| public Editable getEditable() { |
| return mEditable; |
| } |
| |
| @Override |
| public boolean beginBatchEdit() { |
| mEditable.beginBatchEdit(); |
| batchEditNestDepth += 1; |
| return super.beginBatchEdit(); |
| } |
| |
| @Override |
| public boolean endBatchEdit() { |
| boolean result = super.endBatchEdit(); |
| batchEditNestDepth -= 1; |
| mEditable.endBatchEdit(); |
| return result; |
| } |
| |
| @Override |
| public boolean commitText(CharSequence text, int newCursorPosition) { |
| final boolean result = super.commitText(text, newCursorPosition); |
| return result; |
| } |
| |
| @Override |
| public boolean deleteSurroundingText(int beforeLength, int afterLength) { |
| if (mEditable.getSelectionStart() == -1) { |
| return true; |
| } |
| |
| final boolean result = super.deleteSurroundingText(beforeLength, afterLength); |
| return result; |
| } |
| |
| @Override |
| public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { |
| boolean result = super.deleteSurroundingTextInCodePoints(beforeLength, afterLength); |
| return result; |
| } |
| |
| @Override |
| public boolean setComposingRegion(int start, int end) { |
| 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); |
| } |
| endBatchEdit(); |
| return result; |
| } |
| |
| @Override |
| public boolean finishComposingText() { |
| 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) { |
| 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); |
| return result; |
| } |
| |
| @Override |
| public void closeConnection() { |
| super.closeConnection(); |
| mEditable.removeEditingStateListener(this); |
| for (; batchEditNestDepth > 0; batchEditNestDepth--) { |
| endBatchEdit(); |
| } |
| } |
| |
| @Override |
| public boolean setSelection(int start, int end) { |
| beginBatchEdit(); |
| boolean result = super.setSelection(start, end); |
| endBatchEdit(); |
| 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; |
| } |
| |
| // This function is called both when hardware key events occur and aren't |
| // handled by the framework, as well as when soft keyboard editing events |
| // occur, and need a chance to be handled by the framework. |
| @Override |
| public boolean sendKeyEvent(KeyEvent event) { |
| return keyboardDelegate.handleEvent(event); |
| } |
| |
| public boolean handleKeyEvent(KeyEvent event) { |
| if (event.getAction() == KeyEvent.ACTION_DOWN) { |
| if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) { |
| return handleHorizontalMovement(true, event.isShiftPressed()); |
| } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) { |
| return handleHorizontalMovement(false, event.isShiftPressed()); |
| } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_UP) { |
| return handleVerticalMovement(true, event.isShiftPressed()); |
| } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_DOWN) { |
| return handleVerticalMovement(false, event.isShiftPressed()); |
| // 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. |
| final int selStart = Selection.getSelectionStart(mEditable); |
| final int selEnd = Selection.getSelectionEnd(mEditable); |
| final int character = event.getUnicodeChar(); |
| if (selStart < 0 || selEnd < 0 || character == 0) { |
| return false; |
| } |
| |
| final int selMin = Math.min(selStart, selEnd); |
| final int selMax = Math.max(selStart, selEnd); |
| beginBatchEdit(); |
| if (selMin != selMax) mEditable.delete(selMin, selMax); |
| mEditable.insert(selMin, String.valueOf((char) character)); |
| setSelection(selMin + 1, selMin + 1); |
| endBatchEdit(); |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private boolean handleHorizontalMovement(boolean isLeft, boolean isShiftPressed) { |
| final int selStart = Selection.getSelectionStart(mEditable); |
| final int selEnd = Selection.getSelectionEnd(mEditable); |
| |
| if (selStart < 0 || selEnd < 0) { |
| return false; |
| } |
| |
| final int newSelectionEnd = |
| isLeft |
| ? Math.max(flutterTextUtils.getOffsetBefore(mEditable, selEnd), 0) |
| : Math.min(flutterTextUtils.getOffsetAfter(mEditable, selEnd), mEditable.length()); |
| |
| final boolean shouldCollapse = selStart == selEnd && !isShiftPressed; |
| |
| if (shouldCollapse) { |
| setSelection(newSelectionEnd, newSelectionEnd); |
| } else { |
| setSelection(selStart, newSelectionEnd); |
| } |
| return true; |
| }; |
| |
| private boolean handleVerticalMovement(boolean isUp, boolean isShiftPressed) { |
| final int selStart = Selection.getSelectionStart(mEditable); |
| final int selEnd = Selection.getSelectionEnd(mEditable); |
| |
| if (selStart < 0 || selEnd < 0) { |
| return false; |
| } |
| |
| final boolean shouldCollapse = selStart == selEnd && !isShiftPressed; |
| |
| beginBatchEdit(); |
| if (shouldCollapse) { |
| if (isUp) { |
| Selection.moveUp(mEditable, mLayout); |
| } else { |
| Selection.moveDown(mEditable, mLayout); |
| } |
| final int newSelection = Selection.getSelectionStart(mEditable); |
| setSelection(newSelection, newSelection); |
| } else { |
| if (isUp) { |
| Selection.extendUp(mEditable, mLayout); |
| } else { |
| Selection.extendDown(mEditable, mLayout); |
| } |
| setSelection(Selection.getSelectionStart(mEditable), Selection.getSelectionEnd(mEditable)); |
| } |
| endBatchEdit(); |
| return true; |
| } |
| |
| @Override |
| public boolean performContextMenuAction(int id) { |
| 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; |
| } 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 performPrivateCommand(String action, Bundle data) { |
| textInputChannel.performPrivateCommand(mClient, action, data); |
| return true; |
| } |
| |
| @Override |
| public boolean performEditorAction(int actionCode) { |
| 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; |
| } |
| |
| @Override |
| @TargetApi(25) |
| @RequiresApi(25) |
| public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) { |
| // Ensure permission is granted. |
| if (Build.VERSION.SDK_INT >= 25 |
| && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { |
| try { |
| inputContentInfo.requestPermission(); |
| } catch (Exception e) { |
| return false; |
| } |
| } else { |
| return false; |
| } |
| |
| if (inputContentInfo.getDescription().getMimeTypeCount() > 0) { |
| inputContentInfo.requestPermission(); |
| |
| final Uri uri = inputContentInfo.getContentUri(); |
| final String mimeType = inputContentInfo.getDescription().getMimeType(0); |
| Context context = mFlutterView.getContext(); |
| |
| if (uri != null) { |
| InputStream is; |
| try { |
| // Extract byte data from the given URI. |
| is = context.getContentResolver().openInputStream(uri); |
| } catch (FileNotFoundException ex) { |
| inputContentInfo.releasePermission(); |
| return false; |
| } |
| |
| if (is != null) { |
| final byte[] data = this.readStreamFully(is, 64 * 1024); |
| |
| final Map<String, Object> obj = new HashMap<>(); |
| obj.put("mimeType", mimeType); |
| obj.put("data", data); |
| obj.put("uri", uri.toString()); |
| |
| // Commit the content to the text input channel and release the permission. |
| textInputChannel.commitContent(mClient, obj); |
| inputContentInfo.releasePermission(); |
| return true; |
| } |
| } |
| |
| inputContentInfo.releasePermission(); |
| } |
| return false; |
| } |
| |
| private byte[] readStreamFully(InputStream is, int blocksize) { |
| ByteArrayOutputStream baos = new ByteArrayOutputStream(); |
| |
| byte[] buffer = new byte[blocksize]; |
| while (true) { |
| int len = -1; |
| try { |
| len = is.read(buffer); |
| } catch (IOException ex) { |
| } |
| if (len == -1) break; |
| baos.write(buffer, 0, len); |
| } |
| return baos.toByteArray(); |
| } |
| |
| // -------- 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 ------- |
| } |