| // 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.annotation.TargetApi; |
| import android.content.Context; |
| import android.graphics.Insets; |
| import android.graphics.Rect; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.provider.Settings; |
| import android.text.Editable; |
| import android.text.InputType; |
| import android.text.Selection; |
| import android.util.SparseArray; |
| import android.view.View; |
| import android.view.ViewStructure; |
| import android.view.WindowInsets; |
| import android.view.WindowInsetsAnimation; |
| import android.view.autofill.AutofillId; |
| import android.view.autofill.AutofillManager; |
| import android.view.autofill.AutofillValue; |
| import android.view.inputmethod.BaseInputConnection; |
| import android.view.inputmethod.EditorInfo; |
| import android.view.inputmethod.InputConnection; |
| import android.view.inputmethod.InputMethodManager; |
| import android.view.inputmethod.InputMethodSubtype; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RequiresApi; |
| import androidx.annotation.VisibleForTesting; |
| import io.flutter.embedding.engine.systemchannels.TextInputChannel; |
| import io.flutter.plugin.platform.PlatformViewsController; |
| import java.util.HashMap; |
| import java.util.List; |
| |
| /** Android implementation of the text input plugin. */ |
| public class 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> mAutofillConfigurations; |
| @Nullable private Editable mEditable; |
| private boolean mRestartInputPending; |
| @Nullable private InputConnection lastInputConnection; |
| @NonNull private PlatformViewsController platformViewsController; |
| @Nullable private Rect lastClientRect; |
| private final boolean restartAlwaysRequired; |
| private ImeSyncDeferringInsetsCallback imeSyncCallback; |
| |
| // 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( |
| View view, |
| @NonNull TextInputChannel textInputChannel, |
| @NonNull PlatformViewsController platformViewsController) { |
| mView = view; |
| 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 accomodate Android |
| // controlled keyboard animations. |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { |
| int mask = 0; |
| if ((View.SYSTEM_UI_FLAG_HIDE_NAVIGATION & mView.getWindowSystemUiVisibility()) == 0) { |
| mask = mask | WindowInsets.Type.navigationBars(); |
| } |
| if ((View.SYSTEM_UI_FLAG_FULLSCREEN & mView.getWindowSystemUiVisibility()) == 0) { |
| mask = mask | WindowInsets.Type.statusBars(); |
| } |
| imeSyncCallback = |
| new ImeSyncDeferringInsetsCallback( |
| view, |
| mask, // Overlay, insets that should be merged with the deferred insets |
| WindowInsets.Type.ime() // Deferred, insets that will animate |
| ); |
| mView.setWindowInsetsAnimationCallback(imeSyncCallback); |
| mView.setOnApplyWindowInsetsListener(imeSyncCallback); |
| } |
| |
| this.textInputChannel = textInputChannel; |
| textInputChannel.setTextInputMethodHandler( |
| new TextInputChannel.TextInputMethodHandler() { |
| @Override |
| public void show() { |
| showTextInput(mView); |
| } |
| |
| @Override |
| public void hide() { |
| 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) { |
| setPlatformViewTextInputClient(platformViewId); |
| } |
| |
| @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); |
| restartAlwaysRequired = isRestartAlwaysRequired(); |
| } |
| |
| // Loosely based off of |
| // https://github.com/android/user-interface-samples/blob/master/WindowInsetsAnimation/app/src/main/java/com/google/android/samples/insetsanimation/RootViewDeferringInsetsCallback.kt |
| // |
| // When the IME is shown or hidden, it immediately sends an onApplyWindowInsets call |
| // with the final state of the IME. This initial call disrupts the animation, which |
| // causes a flicker in the beginning. |
| // |
| // To fix this, this class extends WindowInsetsAnimation.Callback and implements |
| // OnApplyWindowInsetsListener. We capture and defer the initial call to |
| // onApplyWindowInsets while the animation completes. When the animation |
| // finishes, we can then release the call by invoking it in the onEnd callback |
| // |
| // The WindowInsetsAnimation.Callback extension forwards the new state of the |
| // IME inset from onProgress() to the framework. We also make use of the |
| // onStart callback to detect which calls to onApplyWindowInsets would |
| // interrupt the animation and defer it. |
| // |
| // By implementing OnApplyWindowInsetsListener, we are able to capture Android's |
| // attempts to call the FlutterView's onApplyWindowInsets. When a call to onStart |
| // occurs, we can mark any non-animation calls to onApplyWindowInsets() that |
| // occurs between prepare and start as deferred by using this class' wrapper |
| // implementation to cache the WindowInsets passed in and turn the current call into |
| // a no-op. When onEnd indicates the end of the animation, the deferred call is |
| // dispatched again, this time avoiding any flicker since the animation is now |
| // complete. |
| @VisibleForTesting |
| @TargetApi(30) |
| @RequiresApi(30) |
| @SuppressLint({"NewApi", "Override"}) |
| class ImeSyncDeferringInsetsCallback extends WindowInsetsAnimation.Callback |
| implements View.OnApplyWindowInsetsListener { |
| private int overlayInsetTypes; |
| private int deferredInsetTypes; |
| |
| private View view; |
| private WindowInsets lastWindowInsets; |
| // True when an animation that matches deferredInsetTypes is active. |
| // |
| // While this is active, this class will capture the initial window inset |
| // sent into lastWindowInsets by flagging needsSave to true, and will hold |
| // onto the intitial inset until the animation is completed, when it will |
| // re-dispatch the inset change. |
| private boolean animating = false; |
| // When an animation begins, android sends a WindowInset with the final |
| // state of the animation. When needsSave is true, we know to capture this |
| // initial WindowInset. |
| private boolean needsSave = false; |
| |
| ImeSyncDeferringInsetsCallback( |
| @NonNull View view, int overlayInsetTypes, int deferredInsetTypes) { |
| super(WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE); |
| this.overlayInsetTypes = overlayInsetTypes; |
| this.deferredInsetTypes = deferredInsetTypes; |
| this.view = view; |
| } |
| |
| @Override |
| public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { |
| this.view = view; |
| if (needsSave) { |
| // Store the view and insets for us in onEnd() below. This captured inset |
| // is not part of the animation and instead, represents the final state |
| // of the inset after the animation is completed. Thus, we defer the processing |
| // of this WindowInset until the animation completes. |
| lastWindowInsets = windowInsets; |
| needsSave = false; |
| } |
| if (animating) { |
| // While animation is running, we consume the insets to prevent disrupting |
| // the animation, which skips this implementation and calls the view's |
| // onApplyWindowInsets directly to avoid being consumed here. |
| return WindowInsets.CONSUMED; |
| } |
| |
| // If no animation is happening, pass the insets on to the view's own |
| // inset handling. |
| return view.onApplyWindowInsets(windowInsets); |
| } |
| |
| @Override |
| public void onPrepare(WindowInsetsAnimation animation) { |
| if ((animation.getTypeMask() & deferredInsetTypes) != 0) { |
| animating = true; |
| needsSave = true; |
| } |
| } |
| |
| @Override |
| public WindowInsets onProgress( |
| WindowInsets insets, List<WindowInsetsAnimation> runningAnimations) { |
| if (!animating || needsSave) { |
| return insets; |
| } |
| boolean matching = false; |
| for (WindowInsetsAnimation animation : runningAnimations) { |
| if ((animation.getTypeMask() & deferredInsetTypes) != 0) { |
| matching = true; |
| continue; |
| } |
| } |
| if (!matching) { |
| return insets; |
| } |
| WindowInsets.Builder builder = new WindowInsets.Builder(lastWindowInsets); |
| // Overlay the ime-only insets with the full insets. |
| // |
| // The IME insets passed in by onProgress assumes that the entire animation |
| // occurs above any present navigation and status bars. This causes the |
| // IME inset to be too large for the animation. To remedy this, we merge the |
| // IME inset with other insets present via a subtract + reLu, which causes the |
| // IME inset to be overlaid with any bars present. |
| Insets newImeInsets = |
| Insets.of( |
| 0, |
| 0, |
| 0, |
| Math.max( |
| insets.getInsets(deferredInsetTypes).bottom |
| - insets.getInsets(overlayInsetTypes).bottom, |
| 0)); |
| builder.setInsets(deferredInsetTypes, newImeInsets); |
| // Directly call onApplyWindowInsets of the view as we do not want to pass through |
| // the onApplyWindowInsets defined in this class, which would consume the insets |
| // as if they were a non-animation inset change and cache it for re-dispatch in |
| // onEnd instead. |
| view.onApplyWindowInsets(builder.build()); |
| return insets; |
| } |
| |
| @Override |
| public void onEnd(WindowInsetsAnimation animation) { |
| if (animating && (animation.getTypeMask() & deferredInsetTypes) != 0) { |
| // If we deferred the IME insets and an IME animation has finished, we need to reset |
| // the flags |
| animating = false; |
| |
| // And finally dispatch the deferred insets to the view now. |
| // Ideally we would just call view.requestApplyInsets() and let the normal dispatch |
| // cycle happen, but this happens too late resulting in a visual flicker. |
| // Instead we manually dispatch the most recent WindowInsets to the view. |
| if (lastWindowInsets != null && view != null) { |
| view.dispatchApplyWindowInsets(lastWindowInsets); |
| } |
| } |
| } |
| } |
| |
| @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.PLATFORM_VIEW) { |
| isInputConnectionLocked = true; |
| } |
| } |
| |
| /** |
| * Unlocks the input connection. |
| * |
| * <p>See also: @{link lockPlatformViewInputConnection}. |
| */ |
| public void unlockPlatformViewInputConnection() { |
| 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); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { |
| mView.setWindowInsetsAnimationCallback(null); |
| mView.setOnApplyWindowInsetsListener(null); |
| } |
| } |
| |
| private static int inputTypeFromTextInputType( |
| TextInputChannel.InputType type, |
| boolean obscureText, |
| boolean autocorrect, |
| boolean enableSuggestions, |
| 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; |
| } |
| |
| 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) textType |= InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; |
| } |
| |
| 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; |
| } |
| |
| public InputConnection createInputConnection(View view, EditorInfo outAttrs) { |
| if (inputTarget.type == InputTarget.Type.NO_TARGET) { |
| lastInputConnection = null; |
| return null; |
| } |
| |
| if (inputTarget.type == InputTarget.Type.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.textCapitalization); |
| outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; |
| 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; |
| |
| InputConnectionAdaptor connection = |
| new InputConnectionAdaptor(view, inputTarget.id, textInputChannel, mEditable, outAttrs); |
| outAttrs.initialSelStart = Selection.getSelectionStart(mEditable); |
| outAttrs.initialSelEnd = Selection.getSelectionEnd(mEditable); |
| |
| 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.PLATFORM_VIEW && inputTarget.id == platformViewId) { |
| inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); |
| hideTextInput(mView); |
| mImm.restartInput(mView); |
| mRestartInputPending = false; |
| } |
| } |
| |
| public void sendTextInputAppPrivateCommand(String action, Bundle data) { |
| mImm.sendAppPrivateCommand(mView, action, data); |
| } |
| |
| private void showTextInput(View view) { |
| view.requestFocus(); |
| mImm.showSoftInput(view, 0); |
| } |
| |
| private void hideTextInput(View view) { |
| notifyViewExited(); |
| // Note: 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); |
| } |
| |
| private void notifyViewEntered() { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { |
| return; |
| } |
| |
| final String triggerIdentifier = configuration.autofill.uniqueIdentifier; |
| final int[] offset = new int[2]; |
| mView.getLocationOnScreen(offset); |
| Rect rect = new Rect(lastClientRect); |
| rect.offset(offset[0], offset[1]); |
| afm.notifyViewEntered(mView, triggerIdentifier.hashCode(), rect); |
| } |
| |
| private void notifyViewExited() { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O |
| || afm == null |
| || configuration == null |
| || configuration.autofill == null) { |
| return; |
| } |
| |
| final String triggerIdentifier = configuration.autofill.uniqueIdentifier; |
| afm.notifyViewExited(mView, triggerIdentifier.hashCode()); |
| } |
| |
| private void notifyValueChanged(String newValue) { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || afm == null || !needsAutofill()) { |
| return; |
| } |
| |
| final String triggerIdentifier = configuration.autofill.uniqueIdentifier; |
| afm.notifyValueChanged(mView, triggerIdentifier.hashCode(), AutofillValue.forText(newValue)); |
| } |
| |
| @VisibleForTesting |
| void setTextInputClient(int client, TextInputChannel.Configuration configuration) { |
| inputTarget = new InputTarget(InputTarget.Type.FRAMEWORK_CLIENT, client); |
| updateAutofillConfigurationIfNeeded(configuration); |
| mEditable = Editable.Factory.getInstance().newEditable(""); |
| |
| // setTextInputClient will be followed by a call to setTextInputEditingState. |
| // Do a restartInput at that time. |
| mRestartInputPending = true; |
| unlockPlatformViewInputConnection(); |
| lastClientRect = null; |
| } |
| |
| private void setPlatformViewTextInputClient(int platformViewId) { |
| // 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 but on API 28 devices where the |
| // platform view's |
| // request to show a keyboard was ignored. |
| mView.requestFocus(); |
| inputTarget = new InputTarget(InputTarget.Type.PLATFORM_VIEW, platformViewId); |
| mImm.restartInput(mView); |
| mRestartInputPending = false; |
| } |
| |
| private void applyStateToSelection(TextInputChannel.TextEditState state) { |
| int selStart = state.selectionStart; |
| int selEnd = state.selectionEnd; |
| if (selStart >= 0 |
| && selStart <= mEditable.length() |
| && selEnd >= 0 |
| && selEnd <= mEditable.length()) { |
| Selection.setSelection(mEditable, selStart, selEnd); |
| } else { |
| Selection.removeSelection(mEditable); |
| } |
| } |
| |
| @VisibleForTesting |
| void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { |
| // Always replace the contents of mEditable if the text differs |
| if (!state.text.equals(mEditable.toString())) { |
| mEditable.replace(0, mEditable.length(), state.text); |
| } |
| notifyValueChanged(mEditable.toString()); |
| // Always apply state to selection which handles updating the selection if needed. |
| applyStateToSelection(state); |
| InputConnection connection = getLastInputConnection(); |
| if (connection != null && connection instanceof InputConnectionAdaptor) { |
| ((InputConnectionAdaptor) connection).markDirty(); |
| } |
| // Use updateSelection to update imm on selection if it is not neccessary to restart. |
| if (!restartAlwaysRequired && !mRestartInputPending) { |
| mImm.updateSelection( |
| mView, |
| Math.max(Selection.getSelectionStart(mEditable), 0), |
| Math.max(Selection.getSelectionEnd(mEditable), 0), |
| BaseInputConnection.getComposingSpanStart(mEditable), |
| BaseInputConnection.getComposingSpanEnd(mEditable)); |
| // Restart if there is a pending restart or the device requires a force restart |
| // (see isRestartAlwaysRequired). Restarting will also update the selection. |
| } else { |
| 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)); |
| } |
| |
| private void updateAutofillConfigurationIfNeeded(TextInputChannel.Configuration configuration) { |
| notifyViewExited(); |
| this.configuration = configuration; |
| final TextInputChannel.Configuration[] configurations = configuration.fields; |
| |
| if (configuration.autofill == null) { |
| // Disables autofill if the configuration doesn't have an autofill field. |
| mAutofillConfigurations = null; |
| return; |
| } |
| |
| mAutofillConfigurations = new SparseArray<>(); |
| |
| if (configurations == null) { |
| mAutofillConfigurations.put( |
| configuration.autofill.uniqueIdentifier.hashCode(), configuration); |
| } else { |
| for (TextInputChannel.Configuration config : configurations) { |
| TextInputChannel.Configuration.Autofill autofill = config.autofill; |
| if (autofill == null) { |
| continue; |
| } |
| |
| mAutofillConfigurations.put(autofill.uniqueIdentifier.hashCode(), config); |
| } |
| } |
| } |
| |
| private boolean needsAutofill() { |
| return mAutofillConfigurations != null; |
| } |
| |
| public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !needsAutofill()) { |
| return; |
| } |
| |
| final String triggerIdentifier = configuration.autofill.uniqueIdentifier; |
| final AutofillId parentId = structure.getAutofillId(); |
| for (int i = 0; i < mAutofillConfigurations.size(); i++) { |
| final int autofillId = mAutofillConfigurations.keyAt(i); |
| final TextInputChannel.Configuration config = mAutofillConfigurations.valueAt(i); |
| final TextInputChannel.Configuration.Autofill autofill = config.autofill; |
| if (autofill == null) { |
| continue; |
| } |
| |
| structure.addChildCount(1); |
| final ViewStructure child = structure.newChild(i); |
| child.setAutofillId(parentId, autofillId); |
| child.setAutofillValue(AutofillValue.forText(autofill.editState.text)); |
| child.setAutofillHints(autofill.hints); |
| child.setAutofillType(View.AUTOFILL_TYPE_TEXT); |
| child.setVisibility(View.VISIBLE); |
| |
| // Some autofill services expect child structures to be visible. |
| // Reports the real size of the child if it's the current client. |
| if (triggerIdentifier.hashCode() == autofillId && lastClientRect != null) { |
| child.setDimens( |
| lastClientRect.left, |
| lastClientRect.top, |
| 0, |
| 0, |
| lastClientRect.width(), |
| lastClientRect.height()); |
| } else { |
| // Reports a fake dimension that's still visible. |
| child.setDimens(0, 0, 0, 0, 1, 1); |
| } |
| } |
| } |
| |
| public void autofill(SparseArray<AutofillValue> values) { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { |
| return; |
| } |
| |
| final TextInputChannel.Configuration.Autofill currentAutofill = configuration.autofill; |
| if (currentAutofill == null) { |
| return; |
| } |
| |
| final HashMap<String, TextInputChannel.TextEditState> editingValues = new HashMap<>(); |
| for (int i = 0; i < values.size(); i++) { |
| int virtualId = values.keyAt(i); |
| |
| final TextInputChannel.Configuration config = mAutofillConfigurations.get(virtualId); |
| if (config == null || config.autofill == null) { |
| continue; |
| } |
| |
| final TextInputChannel.Configuration.Autofill autofill = config.autofill; |
| final String value = values.valueAt(i).getTextValue().toString(); |
| final TextInputChannel.TextEditState newState = |
| new TextInputChannel.TextEditState(value, value.length(), value.length()); |
| |
| // The value of the currently focused text field needs to be updated. |
| if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) { |
| setTextInputEditingState(mView, newState); |
| } |
| editingValues.put(autofill.uniqueIdentifier, newState); |
| } |
| |
| textInputChannel.updateEditingStateWithTag(inputTarget.id, editingValues); |
| } |
| |
| // Samsung's Korean keyboard has a bug where it always attempts to combine characters based on |
| // its internal state, ignoring if and when the cursor is moved programmatically. The same bug |
| // also causes non-korean keyboards to occasionally duplicate text when tapping in the middle |
| // of existing text to edit it. |
| // |
| // Fully restarting the IMM works around this because it flushes the keyboard's internal state |
| // and stops it from trying to incorrectly combine characters. However this also has some |
| // negative performance implications, so we don't want to apply this workaround in every case. |
| @SuppressLint("NewApi") // New API guard is inline, the linter can't see it. |
| @SuppressWarnings("deprecation") |
| private boolean isRestartAlwaysRequired() { |
| 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( |
| mView.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"); |
| } |
| |
| private void clearTextInputClient() { |
| if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { |
| // Focus changes in the framework tree have no guarantees on the order focus nodes are |
| // notified. A node |
| // 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 views |
| // 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; |
| } |
| inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); |
| unlockPlatformViewInputConnection(); |
| notifyViewExited(); |
| 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 an embedded platform view. |
| 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; |
| } |
| } |