| // 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.Context; |
| import android.graphics.Rect; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.text.Editable; |
| import android.text.InputType; |
| import android.util.SparseArray; |
| import android.view.KeyEvent; |
| import android.view.View; |
| import android.view.ViewStructure; |
| import android.view.autofill.AutofillId; |
| import android.view.autofill.AutofillManager; |
| import android.view.autofill.AutofillValue; |
| import android.view.inputmethod.EditorInfo; |
| import android.view.inputmethod.InputConnection; |
| import android.view.inputmethod.InputMethodManager; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.core.view.inputmethod.EditorInfoCompat; |
| import io.flutter.Log; |
| import io.flutter.embedding.android.KeyboardManager; |
| import io.flutter.embedding.engine.systemchannels.TextInputChannel; |
| import io.flutter.embedding.engine.systemchannels.TextInputChannel.TextEditState; |
| import io.flutter.plugin.platform.PlatformViewsController; |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| |
| /** Android implementation of the text input plugin. */ |
| 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; |
| @NonNull private final TextInputChannel textInputChannel; |
| @NonNull private InputTarget inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); |
| @Nullable private TextInputChannel.Configuration configuration; |
| @Nullable private SparseArray<TextInputChannel.Configuration> autofillConfiguration; |
| @NonNull private ListenableEditingState mEditable; |
| private boolean mRestartInputPending; |
| @Nullable private InputConnection lastInputConnection; |
| @NonNull private PlatformViewsController platformViewsController; |
| @Nullable private Rect lastClientRect; |
| private ImeSyncDeferringInsetsCallback imeSyncCallback; |
| |
| // 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 |
| // details. |
| private boolean isInputConnectionLocked; |
| |
| @SuppressLint("NewApi") |
| public TextInputPlugin( |
| @NonNull View view, |
| @NonNull TextInputChannel textInputChannel, |
| @NonNull PlatformViewsController platformViewsController) { |
| mView = view; |
| // Create a default object. |
| mEditable = new ListenableEditingState(null, mView); |
| mImm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
| afm = view.getContext().getSystemService(AutofillManager.class); |
| } else { |
| afm = null; |
| } |
| |
| // Sets up syncing ime insets with the framework, allowing |
| // the Flutter view to grow and shrink to accommodate Android |
| // controlled keyboard animations. |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { |
| imeSyncCallback = new ImeSyncDeferringInsetsCallback(view); |
| imeSyncCallback.install(); |
| |
| // When the IME is hidden, we need to notify the framework that close connection. |
| imeSyncCallback.setImeVisibleListener( |
| new ImeSyncDeferringInsetsCallback.ImeVisibleListener() { |
| @Override |
| public void onImeVisibleChanged(boolean visible) { |
| if (!visible) { |
| onConnectionClosed(); |
| } |
| } |
| }); |
| } |
| |
| this.textInputChannel = textInputChannel; |
| textInputChannel.setTextInputMethodHandler( |
| new TextInputChannel.TextInputMethodHandler() { |
| @Override |
| public void show() { |
| showTextInput(mView); |
| } |
| |
| @Override |
| public void hide() { |
| if (inputTarget.type == InputTarget.Type.PHYSICAL_DISPLAY_PLATFORM_VIEW) { |
| notifyViewExited(); |
| } else { |
| hideTextInput(mView); |
| } |
| } |
| |
| @Override |
| public void requestAutofill() { |
| notifyViewEntered(); |
| } |
| |
| @Override |
| public void finishAutofillContext(boolean shouldSave) { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null) { |
| return; |
| } |
| if (shouldSave) { |
| afm.commit(); |
| } else { |
| afm.cancel(); |
| } |
| } |
| |
| @Override |
| public void setClient( |
| int textInputClientId, TextInputChannel.Configuration configuration) { |
| setTextInputClient(textInputClientId, configuration); |
| } |
| |
| @Override |
| public void setPlatformViewClient(int platformViewId, boolean usesVirtualDisplay) { |
| setPlatformViewTextInputClient(platformViewId, usesVirtualDisplay); |
| } |
| |
| @Override |
| public void setEditingState(TextInputChannel.TextEditState editingState) { |
| setTextInputEditingState(mView, editingState); |
| } |
| |
| @Override |
| public void setEditableSizeAndTransform(double width, double height, double[] transform) { |
| saveEditableSizeAndTransform(width, height, transform); |
| } |
| |
| @Override |
| public void clearClient() { |
| clearTextInputClient(); |
| } |
| |
| @Override |
| public void sendAppPrivateCommand(String action, Bundle data) { |
| sendTextInputAppPrivateCommand(action, data); |
| } |
| }); |
| |
| textInputChannel.requestExistingInputState(); |
| |
| this.platformViewsController = platformViewsController; |
| this.platformViewsController.attachTextInputPlugin(this); |
| } |
| |
| @NonNull |
| public InputMethodManager getInputMethodManager() { |
| return mImm; |
| } |
| |
| @VisibleForTesting |
| Editable getEditable() { |
| return mEditable; |
| } |
| |
| @VisibleForTesting |
| ImeSyncDeferringInsetsCallback getImeSyncCallback() { |
| return imeSyncCallback; |
| } |
| |
| /** |
| * Use the current platform view input connection until unlockPlatformViewInputConnection is |
| * called. |
| * |
| * <p>The current input connection instance is cached and any following call to @{link |
| * createInputConnection} returns the cached connection until unlockPlatformViewInputConnection is |
| * called. |
| * |
| * <p>This is a no-op if the current input target isn't a platform view. |
| * |
| * <p>This is used to preserve an input connection when moving a platform view from one virtual |
| * display to another. |
| */ |
| public void lockPlatformViewInputConnection() { |
| if (inputTarget.type == InputTarget.Type.VIRTUAL_DISPLAY_PLATFORM_VIEW) { |
| isInputConnectionLocked = true; |
| } |
| } |
| |
| /** |
| * Unlocks the input connection. |
| * |
| * <p>See also: @{link lockPlatformViewInputConnection}. |
| */ |
| public void unlockPlatformViewInputConnection() { |
| if (inputTarget.type == InputTarget.Type.VIRTUAL_DISPLAY_PLATFORM_VIEW) { |
| isInputConnectionLocked = false; |
| } |
| } |
| |
| /** |
| * Detaches the text input plugin from the platform views controller. |
| * |
| * <p>The TextInputPlugin instance should not be used after calling this. |
| */ |
| @SuppressLint("NewApi") |
| public void destroy() { |
| platformViewsController.detachTextInputPlugin(); |
| textInputChannel.setTextInputMethodHandler(null); |
| notifyViewExited(); |
| mEditable.removeEditingStateListener(this); |
| if (imeSyncCallback != null) { |
| imeSyncCallback.remove(); |
| } |
| } |
| |
| private static int inputTypeFromTextInputType( |
| TextInputChannel.InputType type, |
| boolean obscureText, |
| boolean autocorrect, |
| boolean enableSuggestions, |
| boolean enableIMEPersonalizedLearning, |
| TextInputChannel.TextCapitalization textCapitalization) { |
| if (type.type == TextInputChannel.TextInputType.DATETIME) { |
| return InputType.TYPE_CLASS_DATETIME; |
| } else if (type.type == TextInputChannel.TextInputType.NUMBER) { |
| int textType = InputType.TYPE_CLASS_NUMBER; |
| if (type.isSigned) { |
| textType |= InputType.TYPE_NUMBER_FLAG_SIGNED; |
| } |
| if (type.isDecimal) { |
| textType |= InputType.TYPE_NUMBER_FLAG_DECIMAL; |
| } |
| return textType; |
| } else if (type.type == TextInputChannel.TextInputType.PHONE) { |
| return InputType.TYPE_CLASS_PHONE; |
| } else if (type.type == TextInputChannel.TextInputType.NONE) { |
| return InputType.TYPE_NULL; |
| } |
| |
| int textType = InputType.TYPE_CLASS_TEXT; |
| if (type.type == TextInputChannel.TextInputType.MULTILINE) { |
| textType |= InputType.TYPE_TEXT_FLAG_MULTI_LINE; |
| } else if (type.type == TextInputChannel.TextInputType.EMAIL_ADDRESS) { |
| textType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; |
| } else if (type.type == TextInputChannel.TextInputType.URL) { |
| textType |= InputType.TYPE_TEXT_VARIATION_URI; |
| } else if (type.type == TextInputChannel.TextInputType.VISIBLE_PASSWORD) { |
| textType |= InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD; |
| } else if (type.type == TextInputChannel.TextInputType.NAME) { |
| textType |= InputType.TYPE_TEXT_VARIATION_PERSON_NAME; |
| } else if (type.type == TextInputChannel.TextInputType.POSTAL_ADDRESS) { |
| textType |= InputType.TYPE_TEXT_VARIATION_POSTAL_ADDRESS; |
| } |
| |
| if (obscureText) { |
| // Note: both required. Some devices ignore TYPE_TEXT_FLAG_NO_SUGGESTIONS. |
| textType |= InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; |
| textType |= InputType.TYPE_TEXT_VARIATION_PASSWORD; |
| } else { |
| if (autocorrect) textType |= InputType.TYPE_TEXT_FLAG_AUTO_CORRECT; |
| if (!enableSuggestions) { |
| // Note: both required. Some devices ignore TYPE_TEXT_FLAG_NO_SUGGESTIONS. |
| textType |= InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; |
| textType |= InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD; |
| } |
| } |
| |
| if (textCapitalization == TextInputChannel.TextCapitalization.CHARACTERS) { |
| textType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS; |
| } else if (textCapitalization == TextInputChannel.TextCapitalization.WORDS) { |
| textType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS; |
| } else if (textCapitalization == TextInputChannel.TextCapitalization.SENTENCES) { |
| textType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; |
| } |
| |
| return textType; |
| } |
| |
| @Nullable |
| public InputConnection createInputConnection( |
| @NonNull View view, @NonNull KeyboardManager keyboardManager, @NonNull EditorInfo outAttrs) { |
| if (inputTarget.type == InputTarget.Type.NO_TARGET) { |
| lastInputConnection = null; |
| return null; |
| } |
| |
| if (inputTarget.type == InputTarget.Type.PHYSICAL_DISPLAY_PLATFORM_VIEW) { |
| return null; |
| } |
| |
| if (inputTarget.type == InputTarget.Type.VIRTUAL_DISPLAY_PLATFORM_VIEW) { |
| if (isInputConnectionLocked) { |
| return lastInputConnection; |
| } |
| lastInputConnection = |
| platformViewsController |
| .getPlatformViewById(inputTarget.id) |
| .onCreateInputConnection(outAttrs); |
| return lastInputConnection; |
| } |
| |
| outAttrs.inputType = |
| inputTypeFromTextInputType( |
| configuration.inputType, |
| configuration.obscureText, |
| configuration.autocorrect, |
| configuration.enableSuggestions, |
| configuration.enableIMEPersonalizedLearning, |
| configuration.textCapitalization); |
| outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; |
| |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O |
| && !configuration.enableIMEPersonalizedLearning) { |
| outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING; |
| } |
| |
| int enterAction; |
| if (configuration.inputAction == null) { |
| // If an explicit input action isn't set, then default to none for multi-line fields |
| // and done for single line fields. |
| enterAction = |
| (InputType.TYPE_TEXT_FLAG_MULTI_LINE & outAttrs.inputType) != 0 |
| ? EditorInfo.IME_ACTION_NONE |
| : EditorInfo.IME_ACTION_DONE; |
| } else { |
| enterAction = configuration.inputAction; |
| } |
| if (configuration.actionLabel != null) { |
| outAttrs.actionLabel = configuration.actionLabel; |
| outAttrs.actionId = enterAction; |
| } |
| outAttrs.imeOptions |= enterAction; |
| |
| if (configuration.contentCommitMimeTypes != null) { |
| String[] imgTypeString = configuration.contentCommitMimeTypes; |
| EditorInfoCompat.setContentMimeTypes(outAttrs, imgTypeString); |
| } |
| |
| InputConnectionAdaptor connection = |
| new InputConnectionAdaptor( |
| view, inputTarget.id, textInputChannel, keyboardManager, mEditable, outAttrs); |
| outAttrs.initialSelStart = mEditable.getSelectionStart(); |
| outAttrs.initialSelEnd = mEditable.getSelectionEnd(); |
| |
| lastInputConnection = connection; |
| return lastInputConnection; |
| } |
| |
| @Nullable |
| public InputConnection getLastInputConnection() { |
| return lastInputConnection; |
| } |
| |
| /** |
| * Clears a platform view text input client if it is the current input target. |
| * |
| * <p>This is called when a platform view is disposed to make sure we're not hanging to a stale |
| * input connection. |
| */ |
| public void clearPlatformViewClient(int platformViewId) { |
| if ((inputTarget.type == InputTarget.Type.VIRTUAL_DISPLAY_PLATFORM_VIEW |
| || inputTarget.type == InputTarget.Type.PHYSICAL_DISPLAY_PLATFORM_VIEW) |
| && inputTarget.id == platformViewId) { |
| inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); |
| notifyViewExited(); |
| mImm.hideSoftInputFromWindow(mView.getApplicationWindowToken(), 0); |
| mImm.restartInput(mView); |
| mRestartInputPending = false; |
| } |
| } |
| |
| public void sendTextInputAppPrivateCommand(@NonNull String action, @NonNull Bundle data) { |
| mImm.sendAppPrivateCommand(mView, action, data); |
| } |
| |
| private boolean canShowTextInput() { |
| if (configuration == null || configuration.inputType == null) { |
| return true; |
| } |
| return configuration.inputType.type != TextInputChannel.TextInputType.NONE; |
| } |
| |
| @VisibleForTesting |
| void showTextInput(View view) { |
| if (canShowTextInput()) { |
| view.requestFocus(); |
| mImm.showSoftInput(view, 0); |
| } else { |
| hideTextInput(view); |
| } |
| } |
| |
| private void hideTextInput(View view) { |
| notifyViewExited(); |
| // Note: when a virtual display is used, a race condition may lead to us hiding the keyboard |
| // here just after a platform view has shown it. |
| // This can only potentially happen when switching focus from a Flutter text field to a platform |
| // view's text |
| // field(by text field here I mean anything that keeps the keyboard open). |
| // See: https://github.com/flutter/flutter/issues/34169 |
| mImm.hideSoftInputFromWindow(view.getApplicationWindowToken(), 0); |
| } |
| |
| @VisibleForTesting |
| void setTextInputClient(int client, TextInputChannel.Configuration configuration) { |
| // Call notifyViewExited on the previous field. |
| notifyViewExited(); |
| this.configuration = configuration; |
| if (canShowTextInput()) { |
| inputTarget = new InputTarget(InputTarget.Type.FRAMEWORK_CLIENT, client); |
| } else { |
| inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, client); |
| } |
| |
| mEditable.removeEditingStateListener(this); |
| mEditable = |
| new ListenableEditingState( |
| configuration.autofill != null ? configuration.autofill.editState : null, mView); |
| updateAutofillConfigurationIfNeeded(configuration); |
| |
| // 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, boolean usesVirtualDisplay) { |
| if (usesVirtualDisplay) { |
| // We need to make sure that the Flutter view is focused so that no imm operations get short |
| // circuited. |
| // Not asking for focus here specifically manifested in a bug on API 28 devices where the |
| // platform view's request to show a keyboard was ignored. |
| mView.requestFocus(); |
| inputTarget = new InputTarget(InputTarget.Type.VIRTUAL_DISPLAY_PLATFORM_VIEW, platformViewId); |
| mImm.restartInput(mView); |
| mRestartInputPending = false; |
| } else { |
| inputTarget = |
| new InputTarget(InputTarget.Type.PHYSICAL_DISPLAY_PLATFORM_VIEW, platformViewId); |
| lastInputConnection = null; |
| } |
| } |
| |
| private static boolean composingChanged( |
| TextInputChannel.TextEditState before, TextInputChannel.TextEditState after) { |
| final int composingRegionLength = before.composingEnd - before.composingStart; |
| if (composingRegionLength != after.composingEnd - after.composingStart) { |
| return true; |
| } |
| for (int index = 0; index < composingRegionLength; index++) { |
| if (before.text.charAt(index + before.composingStart) |
| != after.text.charAt(index + after.composingStart)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| // Called by the text input channel to update the text input plugin with the |
| // latest TextEditState from the framework. |
| @VisibleForTesting |
| void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { |
| if (!mRestartInputPending |
| && mLastKnownFrameworkTextEditingState != null |
| && mLastKnownFrameworkTextEditingState.hasComposing()) { |
| // Also restart input if the framework (or the developer) decides to |
| // change the composing region by itself (which is discouraged). Many IMEs |
| // don't expect editors to commit composing text, so a restart is needed |
| // to reset their internal states. |
| mRestartInputPending = composingChanged(mLastKnownFrameworkTextEditingState, state); |
| if (mRestartInputPending) { |
| Log.i(TAG, "Composing region changed by the framework. Restarting the input method."); |
| } |
| } |
| |
| mLastKnownFrameworkTextEditingState = state; |
| mEditable.setEditingState(state); |
| |
| // Restart if needed. Restarting will also update the selection. |
| if (mRestartInputPending) { |
| mImm.restartInput(view); |
| mRestartInputPending = false; |
| } |
| } |
| |
| private interface MinMax { |
| void inspect(double x, double y); |
| } |
| |
| private void saveEditableSizeAndTransform(double width, double height, double[] matrix) { |
| final double[] minMax = new double[4]; // minX, maxX, minY, maxY. |
| final boolean isAffine = matrix[3] == 0 && matrix[7] == 0 && matrix[15] == 1; |
| minMax[0] = minMax[1] = matrix[12] / matrix[15]; // minX and maxX. |
| minMax[2] = minMax[3] = matrix[13] / matrix[15]; // minY and maxY. |
| |
| final MinMax finder = |
| new MinMax() { |
| @Override |
| public void inspect(double x, double y) { |
| final double w = isAffine ? 1 : 1 / (matrix[3] * x + matrix[7] * y + matrix[15]); |
| final double tx = (matrix[0] * x + matrix[4] * y + matrix[12]) * w; |
| final double ty = (matrix[1] * x + matrix[5] * y + matrix[13]) * w; |
| |
| if (tx < minMax[0]) { |
| minMax[0] = tx; |
| } else if (tx > minMax[1]) { |
| minMax[1] = tx; |
| } |
| |
| if (ty < minMax[2]) { |
| minMax[2] = ty; |
| } else if (ty > minMax[3]) { |
| minMax[3] = ty; |
| } |
| } |
| }; |
| |
| finder.inspect(width, 0); |
| finder.inspect(width, height); |
| finder.inspect(0, height); |
| final Float density = mView.getContext().getResources().getDisplayMetrics().density; |
| lastClientRect = |
| new Rect( |
| (int) (minMax[0] * density), |
| (int) (minMax[2] * density), |
| (int) Math.ceil(minMax[1] * density), |
| (int) Math.ceil(minMax[3] * density)); |
| } |
| |
| @VisibleForTesting |
| void clearTextInputClient() { |
| if (inputTarget.type == InputTarget.Type.VIRTUAL_DISPLAY_PLATFORM_VIEW) { |
| // This only applies to platform views that use a virtual display. |
| // Focus changes in the framework tree have no guarantees on the order focus nodes are |
| // notified. A node that lost focus may be notified before or after a node that gained focus. |
| // When moving the focus from a Flutter text field to an AndroidView, it is possible that the |
| // Flutter text 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 |
| // way to do so is to set a new framework text client). I don't see an obvious use case for |
| // "clearing" a 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. |
| // If we ever want to allow the framework to clear a platform view text client we should |
| // probably consider changing the focus manager such that focus nodes that lost focus are |
| // notified before focus nodes that gained focus as part of the same focus event. |
| return; |
| } |
| mEditable.removeEditingStateListener(this); |
| notifyViewExited(); |
| configuration = null; |
| updateAutofillConfigurationIfNeeded(null); |
| inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); |
| unlockPlatformViewInputConnection(); |
| lastClientRect = null; |
| } |
| |
| private static class InputTarget { |
| enum Type { |
| NO_TARGET, |
| // InputConnection is managed by the TextInputPlugin, and events are forwarded to the Flutter |
| // framework. |
| FRAMEWORK_CLIENT, |
| // InputConnection is managed by a platform view that is presented on a virtual display. |
| VIRTUAL_DISPLAY_PLATFORM_VIEW, |
| // InputConnection is managed by a platform view that is embedded in the activity's view |
| // hierarchy. This view hierarchy is displayed in a physical display within the aplication |
| // display area. |
| PHYSICAL_DISPLAY_PLATFORM_VIEW, |
| } |
| |
| public InputTarget(@NonNull Type type, int id) { |
| this.type = type; |
| this.id = id; |
| } |
| |
| @NonNull Type type; |
| // The ID of the input target. |
| // |
| // For framework clients this is the framework input connection client ID. |
| // For platform views this is the platform view's ID. |
| int id; |
| } |
| |
| // -------- Start: KeyboardManager Synchronous Responder ------- |
| public boolean handleKeyEvent(@NonNull KeyEvent keyEvent) { |
| if (!getInputMethodManager().isAcceptingText() || lastInputConnection == null) { |
| return false; |
| } |
| |
| // Send the KeyEvent as an IME KeyEvent. If the input connection is an |
| // InputConnectionAdaptor then call its handleKeyEvent method (because |
| // this method will be called by the keyboard manager, and |
| // InputConnectionAdaptor#sendKeyEvent forwards the key event back to the |
| // keyboard manager). |
| return (lastInputConnection instanceof InputConnectionAdaptor) |
| ? ((InputConnectionAdaptor) lastInputConnection).handleKeyEvent(keyEvent) |
| : lastInputConnection.sendKeyEvent(keyEvent); |
| } |
| // -------- End: KeyboardManager Synchronous Responder ------- |
| |
| // -------- 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(); |
| |
| final ArrayList<TextEditingDelta> batchTextEditingDeltas = |
| mEditable.extractBatchTextEditingDeltas(); |
| final boolean skipFrameworkUpdate = |
| // The framework needs to send its editing state first. |
| mLastKnownFrameworkTextEditingState == null |
| || (mEditable.toString().equals(mLastKnownFrameworkTextEditingState.text) |
| && selectionStart == mLastKnownFrameworkTextEditingState.selectionStart |
| && selectionEnd == mLastKnownFrameworkTextEditingState.selectionEnd |
| && composingStart == mLastKnownFrameworkTextEditingState.composingStart |
| && composingEnd == mLastKnownFrameworkTextEditingState.composingEnd); |
| if (!skipFrameworkUpdate) { |
| Log.v(TAG, "send EditingState to flutter: " + mEditable.toString()); |
| |
| if (configuration.enableDeltaModel) { |
| textInputChannel.updateEditingStateWithDeltas(inputTarget.id, batchTextEditingDeltas); |
| mEditable.clearBatchDeltas(); |
| } else { |
| textInputChannel.updateEditingState( |
| inputTarget.id, |
| mEditable.toString(), |
| selectionStart, |
| selectionEnd, |
| composingStart, |
| composingEnd); |
| } |
| mLastKnownFrameworkTextEditingState = |
| new TextEditState( |
| mEditable.toString(), selectionStart, selectionEnd, composingStart, composingEnd); |
| } else { |
| // Don't accumulate deltas if they are not sent to the framework. |
| mEditable.clearBatchDeltas(); |
| } |
| } |
| |
| // -------- End: ListenableEditingState watcher implementation ------- |
| |
| // -------- Start: Autofill ------- |
| // ### Setup and provide the initial text values and hints. |
| // |
| // The TextInputConfiguration used to set up 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 autofillConfiguration != 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 |
| || !needsAutofill()) { |
| 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; |
| } |
| |
| if (configuration == null || configuration.autofill == null) { |
| // Disables autofill if the configuration doesn't have an autofill field. |
| autofillConfiguration = null; |
| return; |
| } |
| |
| final TextInputChannel.Configuration[] configurations = configuration.fields; |
| autofillConfiguration = new SparseArray<>(); |
| |
| if (configurations == null) { |
| autofillConfiguration.put(configuration.autofill.uniqueIdentifier.hashCode(), configuration); |
| } else { |
| for (TextInputChannel.Configuration config : configurations) { |
| TextInputChannel.Configuration.Autofill autofill = config.autofill; |
| if (autofill != null) { |
| autofillConfiguration.put(autofill.uniqueIdentifier.hashCode(), config); |
| afm.notifyValueChanged( |
| mView, |
| autofill.uniqueIdentifier.hashCode(), |
| AutofillValue.forText(autofill.editState.text)); |
| } |
| } |
| } |
| } |
| |
| public void onProvideAutofillVirtualStructure(@NonNull 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 < autofillConfiguration.size(); i++) { |
| final int autofillId = autofillConfiguration.keyAt(i); |
| final TextInputChannel.Configuration config = autofillConfiguration.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); |
| // Don't set hints when there's none. |
| // See https://github.com/flutter/flutter/issues/98505. |
| if (autofill.hints.length > 0) { |
| child.setAutofillHints(autofill.hints); |
| } |
| child.setAutofillType(View.AUTOFILL_TYPE_TEXT); |
| child.setVisibility(View.VISIBLE); |
| if (autofill.hintText != null) { |
| child.setHint(autofill.hintText); |
| } |
| |
| // 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(@NonNull SparseArray<AutofillValue> values) { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { |
| return; |
| } |
| |
| if (configuration == null || autofillConfiguration == null || configuration.autofill == null) { |
| return; |
| } |
| |
| final TextInputChannel.Configuration.Autofill currentAutofill = configuration.autofill; |
| 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 = autofillConfiguration.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); |
| |
| if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) { |
| // Autofilling the current client is the same as handling user input |
| // from the virtual keyboard. Setting the editable to newState and an |
| // update will be sent to the framework. |
| mEditable.setEditingState(newState); |
| } else { |
| editingValues.put(autofill.uniqueIdentifier, newState); |
| } |
| } |
| textInputChannel.updateEditingStateWithTag(inputTarget.id, editingValues); |
| } |
| // -------- End: Autofill ------- |
| |
| public void onConnectionClosed() { |
| textInputChannel.onConnectionClosed(inputTarget.id); |
| } |
| } |