diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 31cbeb9673484..33e50f295ae05 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -14,12 +14,15 @@ import android.os.LocaleList; import android.text.format.DateFormat; import android.util.AttributeSet; +import android.util.SparseArray; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; +import android.view.ViewStructure; import android.view.WindowInsets; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeProvider; +import android.view.autofill.AutofillValue; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.widget.FrameLayout; @@ -283,6 +286,9 @@ private void init() { // FlutterView needs to be focusable so that the InputMethodManager can interact with it. setFocusable(true); setFocusableInTouchMode(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES_EXCLUDE_DESCENDANTS); + } } /** @@ -898,6 +904,17 @@ private void sendViewportMetricsToFlutter() { flutterEngine.getRenderer().setViewportMetrics(viewportMetrics); } + @Override + public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) { + super.onProvideAutofillVirtualStructure(structure, flags); + textInputPlugin.onProvideAutofillVirtualStructure(structure, flags); + } + + @Override + public void autofill(SparseArray values) { + textInputPlugin.autofill(values); + } + /** * Render modes for a {@link FlutterView}. * diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index 80d2e419c8fce..a8fe0d5f95784 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -1,5 +1,7 @@ package io.flutter.embedding.engine.systemchannels; +import android.os.Build; +import android.view.View; import android.view.inputmethod.EditorInfo; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -10,6 +12,7 @@ import io.flutter.plugin.common.MethodChannel; import java.util.Arrays; import java.util.HashMap; +import java.util.Map; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -85,6 +88,22 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result.error("error", exception.getMessage(), null); } break; + case "TextInput.setEditableSizeAndTransform": + try { + final JSONObject arguments = (JSONObject) args; + final double width = arguments.getDouble("width"); + final double height = arguments.getDouble("height"); + final JSONArray jsonMatrix = arguments.getJSONArray("transform"); + final double[] matrix = new double[16]; + for (int i = 0; i < 16; i++) { + matrix[i] = jsonMatrix.getDouble(i); + } + + textInputMethodHandler.setEditableSizeAndTransform(width, height, matrix); + } catch (JSONException exception) { + result.error("error", exception.getMessage(), null); + } + break; case "TextInput.clearClient": textInputMethodHandler.clearClient(); result.success(null); @@ -119,6 +138,16 @@ public void requestExistingInputState() { channel.invokeMethod("TextInputClient.requestExistingInputState", null); } + private static HashMap createEditingStateJSON( + String text, int selectionStart, int selectionEnd, int composingStart, int composingEnd) { + HashMap state = new HashMap<>(); + state.put("text", text); + state.put("selectionBase", selectionStart); + state.put("selectionExtent", selectionEnd); + state.put("composingBase", composingStart); + state.put("composingExtent", composingEnd); + return state; + } /** * Instructs Flutter to update its text input editing state to reflect the given configuration. */ @@ -147,16 +176,31 @@ public void updateEditingState( + "Composing end: " + composingEnd); - HashMap state = new HashMap<>(); - state.put("text", text); - state.put("selectionBase", selectionStart); - state.put("selectionExtent", selectionEnd); - state.put("composingBase", composingStart); - state.put("composingExtent", composingEnd); + final HashMap state = + createEditingStateJSON(text, selectionStart, selectionEnd, composingStart, composingEnd); channel.invokeMethod("TextInputClient.updateEditingState", Arrays.asList(inputClientId, state)); } + public void updateEditingStateWithTag( + int inputClientId, HashMap editStates) { + Log.v( + TAG, + "Sending message to update editing state for " + + String.valueOf(editStates.size()) + + " field(s)."); + + final HashMap> json = new HashMap<>(); + for (Map.Entry element : editStates.entrySet()) { + final TextEditState state = element.getValue(); + json.put( + element.getKey(), + createEditingStateJSON(state.text, state.selectionStart, state.selectionEnd, -1, -1)); + } + channel.invokeMethod( + "TextInputClient.updateEditingStateWithTag", Arrays.asList(inputClientId, json)); + } + /** Instructs Flutter to execute a "newline" action. */ public void newline(int inputClientId) { Log.v(TAG, "Sending 'newline' message."); @@ -229,6 +273,13 @@ public interface TextInputMethodHandler { // TODO(mattcarroll): javadoc void hide(); + /** + * Requests that the autofill dropdown menu appear for the current client. + * + *

Has no effect if the current client does not support autofill. + */ + void requestAutofill(); + // TODO(mattcarroll): javadoc void setClient(int textInputClientId, @NonNull Configuration configuration); @@ -242,6 +293,16 @@ public interface TextInputMethodHandler { */ void setPlatformViewClient(int id); + /** + * Sets the size and the transform matrix of the current text input client. + * + * @param width the width of text input client. Must be finite. + * @param height the height of text input client. Must be finite. + * @param transform a 4x4 matrix that maps the local paint coordinate system to coordinate + * system of the FlutterView that owns the current client. + */ + void setEditableSizeAndTransform(double width, double height, double[] transform); + // TODO(mattcarroll): javadoc void setEditingState(@NonNull TextEditState editingState); @@ -257,7 +318,14 @@ public static Configuration fromJson(@NonNull JSONObject json) if (inputActionName == null) { throw new JSONException("Configuration JSON missing 'inputAction' property."); } - + Configuration[] fields = null; + if (!json.isNull("fields")) { + final JSONArray jsonFields = json.getJSONArray("fields"); + fields = new Configuration[jsonFields.length()]; + for (int i = 0; i < fields.length; i++) { + fields[i] = Configuration.fromJson(jsonFields.getJSONObject(i)); + } + } final Integer inputAction = inputActionFromTextInputAction(inputActionName); return new Configuration( json.optBoolean("obscureText"), @@ -266,7 +334,9 @@ public static Configuration fromJson(@NonNull JSONObject json) TextCapitalization.fromValue(json.getString("textCapitalization")), InputType.fromJson(json.getJSONObject("inputType")), inputAction, - json.isNull("actionLabel") ? null : json.getString("actionLabel")); + json.isNull("actionLabel") ? null : json.getString("actionLabel"), + json.isNull("autofill") ? null : Autofill.fromJson(json.getJSONObject("autofill")), + fields); } @NonNull @@ -296,6 +366,117 @@ private static Integer inputActionFromTextInputAction(@NonNull String inputActio } } + public static class Autofill { + public static Autofill fromJson(@NonNull JSONObject json) + throws JSONException, NoSuchFieldException { + final String uniqueIdentifier = json.getString("uniqueIdentifier"); + final JSONArray hints = json.getJSONArray("hints"); + final JSONObject editingState = json.getJSONObject("editingValue"); + final String[] hintList = new String[hints.length()]; + + for (int i = 0; i < hintList.length; i++) { + hintList[i] = translateAutofillHint(hints.getString(i)); + } + return new Autofill(uniqueIdentifier, hintList, TextEditState.fromJson(editingState)); + } + + public final String uniqueIdentifier; + public final String[] hints; + public final TextEditState editState; + + @NonNull + private static String translateAutofillHint(@NonNull String hint) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return hint; + } + switch (hint) { + case "addressCity": + return "addressLocality"; + case "addressState": + return "addressRegion"; + case "birthday": + return "birthDateFull"; + case "birthdayDay": + return "birthDateDay"; + case "birthdayMonth": + return "birthDateMonth"; + case "birthdayYear": + return "birthDateYear"; + case "countryName": + return "addressCountry"; + case "creditCardExpirationDate": + return View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE; + case "creditCardExpirationDay": + return View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY; + case "creditCardExpirationMonth": + return View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH; + case "creditCardExpirationYear": + return View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR; + case "creditCardNumber": + return View.AUTOFILL_HINT_CREDIT_CARD_NUMBER; + case "creditCardSecurityCode": + return View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE; + case "email": + return View.AUTOFILL_HINT_EMAIL_ADDRESS; + case "familyName": + return "personFamilyName"; + case "fullStreetAddress": + return "streetAddress"; + case "gender": + return "gender"; + case "givenName": + return "personGivenName"; + case "middleInitial": + return "personMiddleInitial"; + case "middleName": + return "personMiddleName"; + case "name": + return "personName"; + case "namePrefix": + return "personNamePrefix"; + case "nameSuffix": + return "personNameSuffix"; + case "newPassword": + return "newPassword"; + case "newUsername": + return "newUsername"; + case "oneTimeCode": + return "smsOTPCode"; + case "password": + return View.AUTOFILL_HINT_PASSWORD; + case "postalAddress": + return View.AUTOFILL_HINT_POSTAL_ADDRESS; + case "postalAddressExtended": + return "extendedAddress"; + case "postalAddressExtendedPostalCode": + return "extendedPostalCode"; + case "postalCode": + return View.AUTOFILL_HINT_POSTAL_CODE; + case "telephoneNumber": + return "phoneNumber"; + case "telephoneNumberCountryCode": + return "phoneCountryCode"; + case "telephoneNumberDevice": + return "phoneNumberDevice"; + case "telephoneNumberNational": + return "phoneNational"; + case "username": + return View.AUTOFILL_HINT_USERNAME; + default: + return hint; + } + } + + public Autofill( + @NonNull String uniqueIdentifier, + @NonNull String[] hints, + @NonNull TextEditState editingState) { + this.uniqueIdentifier = uniqueIdentifier; + this.hints = hints; + this.editState = editingState; + } + } + public final boolean obscureText; public final boolean autocorrect; public final boolean enableSuggestions; @@ -303,6 +484,8 @@ private static Integer inputActionFromTextInputAction(@NonNull String inputActio @NonNull public final InputType inputType; @Nullable public final Integer inputAction; @Nullable public final String actionLabel; + @Nullable public final Autofill autofill; + @Nullable public final Configuration[] fields; public Configuration( boolean obscureText, @@ -311,7 +494,9 @@ public Configuration( @NonNull TextCapitalization textCapitalization, @NonNull InputType inputType, @Nullable Integer inputAction, - @Nullable String actionLabel) { + @Nullable String actionLabel, + @Nullable Autofill autofill, + @Nullable Configuration[] fields) { this.obscureText = obscureText; this.autocorrect = autocorrect; this.enableSuggestions = enableSuggestions; @@ -319,6 +504,8 @@ public Configuration( this.inputType = inputType; this.inputAction = inputAction; this.actionLabel = actionLabel; + this.autofill = autofill; + this.fields = fields; } } diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 17ccea06728de..b14ac4112f611 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -6,12 +6,18 @@ import android.annotation.SuppressLint; import android.content.Context; +import android.graphics.Rect; import android.os.Build; 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.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; @@ -23,18 +29,22 @@ import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.embedding.engine.systemchannels.TextInputChannel; import io.flutter.plugin.platform.PlatformViewsController; +import java.util.HashMap; /** 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 mAutofillConfigurations; @Nullable private Editable mEditable; private boolean mRestartInputPending; @Nullable private InputConnection lastInputConnection; @NonNull private PlatformViewsController platformViewsController; + @Nullable private Rect lastClientRect; private final boolean restartAlwaysRequired; // When true following calls to createInputConnection will return the cached lastInputConnection @@ -49,6 +59,11 @@ public TextInputPlugin( @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; + } textInputChannel = new TextInputChannel(dartExecutor); textInputChannel.setTextInputMethodHandler( @@ -63,6 +78,11 @@ public void hide() { hideTextInput(mView); } + @Override + public void requestAutofill() { + notifyViewEntered(); + } + @Override public void setClient( int textInputClientId, TextInputChannel.Configuration configuration) { @@ -79,6 +99,11 @@ 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(); @@ -268,6 +293,7 @@ private void showTextInput(View view) { } 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 @@ -277,16 +303,51 @@ private void hideTextInput(View view) { 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); - this.configuration = configuration; + 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) { @@ -320,6 +381,7 @@ void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { 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(); @@ -342,6 +404,141 @@ void setTextInputEditingState(View view, TextInputChannel.TextEditState state) { } } + 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); + } + } + + public void autofill(SparseArray values) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + final TextInputChannel.Configuration.Autofill currentAutofill = configuration.autofill; + if (currentAutofill == null) { + return; + } + + final HashMap 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 @@ -394,6 +591,8 @@ private void clearTextInputClient() { } inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); unlockPlatformViewInputConnection(); + notifyViewExited(); + lastClientRect = null; } private static class InputTarget { diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index 23362b64a3499..40fcea0aceec5 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -21,15 +21,18 @@ import android.text.format.DateFormat; import android.util.AttributeSet; import android.util.Log; +import android.util.SparseArray; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.View; +import android.view.ViewStructure; import android.view.WindowInsets; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeProvider; +import android.view.autofill.AutofillValue; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; @@ -445,6 +448,17 @@ public boolean checkInputConnectionProxy(View view) { .checkInputConnectionProxy(view); } + @Override + public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) { + super.onProvideAutofillVirtualStructure(structure, flags); + mTextInputPlugin.onProvideAutofillVirtualStructure(structure, flags); + } + + @Override + public void autofill(SparseArray values) { + mTextInputPlugin.autofill(values); + } + @Override public boolean onTouchEvent(MotionEvent event) { if (!isAttached()) { diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index b2ecfd7b13fd6..53fb4297875e8 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -2,11 +2,15 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.mockito.AdditionalMatchers.aryEq; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import android.content.Context; import android.content.res.AssetManager; @@ -15,11 +19,13 @@ import android.util.SparseIntArray; import android.view.KeyEvent; import android.view.View; +import android.view.ViewStructure; import android.view.inputmethod.CursorAnchorInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodSubtype; +import io.flutter.embedding.android.FlutterView; import io.flutter.embedding.engine.FlutterJNI; import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.embedding.engine.systemchannels.TextInputChannel; @@ -103,7 +109,15 @@ public void setTextInputEditingState_doesNotRestartWhenTextIsIdentical() { textInputPlugin.setTextInputClient( 0, new TextInputChannel.Configuration( - false, false, true, TextInputChannel.TextCapitalization.NONE, null, null, null)); + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( testView, new TextInputChannel.TextEditState("", 0, 0)); @@ -132,7 +146,15 @@ public void setTextInputEditingState_alwaysSetEditableWhenDifferent() { textInputPlugin.setTextInputClient( 0, new TextInputChannel.Configuration( - false, false, true, TextInputChannel.TextCapitalization.NONE, null, null, null)); + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null)); // There's a pending restart since we initialized the text input client. Flush that now. With // changed text, we should // always set the Editable contents. @@ -173,7 +195,15 @@ public void setTextInputEditingState_alwaysRestartsOnAffectedDevices2() { textInputPlugin.setTextInputClient( 0, new TextInputChannel.Configuration( - false, false, true, TextInputChannel.TextCapitalization.NONE, null, null, null)); + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( testView, new TextInputChannel.TextEditState("", 0, 0)); @@ -208,7 +238,15 @@ public void setTextInputEditingState_doesNotRestartOnUnaffectedDevices() { textInputPlugin.setTextInputClient( 0, new TextInputChannel.Configuration( - false, false, true, TextInputChannel.TextCapitalization.NONE, null, null, null)); + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( testView, new TextInputChannel.TextEditState("", 0, 0)); @@ -236,7 +274,15 @@ public void setTextInputEditingState_nullInputMethodSubtype() { textInputPlugin.setTextInputClient( 0, new TextInputChannel.Configuration( - false, false, true, TextInputChannel.TextCapitalization.NONE, null, null, null)); + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + null, + null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( testView, new TextInputChannel.TextEditState("", 0, 0)); @@ -262,6 +308,8 @@ public void inputConnection_createsActionFromEnter() throws JSONException { TextInputChannel.TextCapitalization.NONE, new TextInputChannel.InputType(TextInputChannel.TextInputType.TEXT, false, false), null, + null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( @@ -331,6 +379,8 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException TextInputChannel.TextCapitalization.NONE, new TextInputChannel.InputType(TextInputChannel.TextInputType.TEXT, false, false), null, + null, + null, null)); // There's a pending restart since we initialized the text input client. Flush that now. textInputPlugin.setTextInputEditingState( @@ -347,6 +397,116 @@ public void inputConnection_finishComposingTextUpdatesIMM() throws JSONException } } + @Test + public void autofill_onProvideVirtualViewStructure() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputPlugin textInputPlugin = + new TextInputPlugin( + testView, mock(DartExecutor.class), mock(PlatformViewsController.class)); + final TextInputChannel.Configuration.Autofill autofill1 = + new TextInputChannel.Configuration.Autofill( + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); + final TextInputChannel.Configuration.Autofill autofill2 = + new TextInputChannel.Configuration.Autofill( + "2", new String[] {"HINT2", "EXTRA"}, new TextInputChannel.TextEditState("", 0, 0)); + + final TextInputChannel.Configuration config1 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + null); + final TextInputChannel.Configuration config2 = + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill2, + null); + + textInputPlugin.setTextInputClient( + 0, + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill1, + new TextInputChannel.Configuration[] {config1, config2})); + + final ViewStructure viewStructure = mock(ViewStructure.class); + final ViewStructure[] children = {mock(ViewStructure.class), mock(ViewStructure.class)}; + + when(viewStructure.newChild(anyInt())) + .thenAnswer(invocation -> children[invocation.getArgumentAt(0, int.class)]); + + textInputPlugin.onProvideAutofillVirtualStructure(viewStructure, 0); + + verify(viewStructure).newChild(0); + verify(viewStructure).newChild(1); + + verify(children[0]).setAutofillId(any(), eq("1".hashCode())); + verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); + verify(children[1]).setAutofillId(any(), eq("2".hashCode())); + verify(children[1]).setAutofillHints(aryEq(new String[] {"HINT2", "EXTRA"})); + } + + @Test + public void autofill_onProvideVirtualViewStructure_single() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + FlutterView testView = new FlutterView(RuntimeEnvironment.application); + TextInputPlugin textInputPlugin = + new TextInputPlugin( + testView, mock(DartExecutor.class), mock(PlatformViewsController.class)); + final TextInputChannel.Configuration.Autofill autofill = + new TextInputChannel.Configuration.Autofill( + "1", new String[] {"HINT1"}, new TextInputChannel.TextEditState("", 0, 0)); + + // Autofill should still work without AutofillGroup. + textInputPlugin.setTextInputClient( + 0, + new TextInputChannel.Configuration( + false, + false, + true, + TextInputChannel.TextCapitalization.NONE, + null, + null, + null, + autofill, + null)); + + final ViewStructure viewStructure = mock(ViewStructure.class); + final ViewStructure[] children = {mock(ViewStructure.class)}; + + when(viewStructure.newChild(anyInt())) + .thenAnswer(invocation -> children[invocation.getArgumentAt(0, int.class)]); + + textInputPlugin.onProvideAutofillVirtualStructure(viewStructure, 0); + + verify(viewStructure).newChild(0); + + verify(children[0]).setAutofillId(any(), eq("1".hashCode())); + verify(children[0]).setAutofillHints(aryEq(new String[] {"HINT1"})); + } + @Implements(InputMethodManager.class) public static class TestImm extends ShadowInputMethodManager { private InputMethodSubtype currentInputMethodSubtype;