mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Android text input autofill (flutter/engine#17465)
This commit is contained in:
parent
fd18ae6aeb
commit
1afb95d156
@ -14,12 +14,15 @@ import android.os.Build;
|
||||
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 @@ public class FlutterView extends FrameLayout {
|
||||
// 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 @@ public class FlutterView extends FrameLayout {
|
||||
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<AutofillValue> values) {
|
||||
textInputPlugin.autofill(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render modes for a {@link FlutterView}.
|
||||
*
|
||||
|
||||
@ -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.MethodCall;
|
||||
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 class TextInputChannel {
|
||||
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 class TextInputChannel {
|
||||
channel.invokeMethod("TextInputClient.requestExistingInputState", null);
|
||||
}
|
||||
|
||||
private static HashMap<Object, Object> createEditingStateJSON(
|
||||
String text, int selectionStart, int selectionEnd, int composingStart, int composingEnd) {
|
||||
HashMap<Object, Object> 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 class TextInputChannel {
|
||||
+ "Composing end: "
|
||||
+ composingEnd);
|
||||
|
||||
HashMap<Object, Object> 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<Object, Object> state =
|
||||
createEditingStateJSON(text, selectionStart, selectionEnd, composingStart, composingEnd);
|
||||
|
||||
channel.invokeMethod("TextInputClient.updateEditingState", Arrays.asList(inputClientId, state));
|
||||
}
|
||||
|
||||
public void updateEditingStateWithTag(
|
||||
int inputClientId, HashMap<String, TextEditState> editStates) {
|
||||
Log.v(
|
||||
TAG,
|
||||
"Sending message to update editing state for "
|
||||
+ String.valueOf(editStates.size())
|
||||
+ " field(s).");
|
||||
|
||||
final HashMap<String, HashMap<Object, Object>> json = new HashMap<>();
|
||||
for (Map.Entry<String, TextEditState> 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 class TextInputChannel {
|
||||
// TODO(mattcarroll): javadoc
|
||||
void hide();
|
||||
|
||||
/**
|
||||
* Requests that the autofill dropdown menu appear for the current client.
|
||||
*
|
||||
* <p>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 class TextInputChannel {
|
||||
*/
|
||||
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 class TextInputChannel {
|
||||
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 class TextInputChannel {
|
||||
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 @@ public class TextInputChannel {
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ public class TextInputChannel {
|
||||
@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 class TextInputChannel {
|
||||
@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 class TextInputChannel {
|
||||
this.inputType = inputType;
|
||||
this.inputAction = inputAction;
|
||||
this.actionLabel = actionLabel;
|
||||
this.autofill = autofill;
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,12 +6,18 @@ package io.flutter.plugin.editing;
|
||||
|
||||
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 androidx.annotation.VisibleForTesting;
|
||||
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<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;
|
||||
|
||||
// When true following calls to createInputConnection will return the cached lastInputConnection
|
||||
@ -49,6 +59,11 @@ public class 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 class TextInputPlugin {
|
||||
hideTextInput(mView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestAutofill() {
|
||||
notifyViewEntered();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClient(
|
||||
int textInputClientId, TextInputChannel.Configuration configuration) {
|
||||
@ -79,6 +99,11 @@ public class TextInputPlugin {
|
||||
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 @@ public class TextInputPlugin {
|
||||
}
|
||||
|
||||
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 @@ public class TextInputPlugin {
|
||||
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 @@ public class TextInputPlugin {
|
||||
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 @@ public class TextInputPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
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<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
|
||||
@ -394,6 +591,8 @@ public class TextInputPlugin {
|
||||
}
|
||||
inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0);
|
||||
unlockPlatformViewInputConnection();
|
||||
notifyViewExited();
|
||||
lastClientRect = null;
|
||||
}
|
||||
|
||||
private static class InputTarget {
|
||||
|
||||
@ -21,15 +21,18 @@ import android.os.LocaleList;
|
||||
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 class FlutterView extends SurfaceView implements BinaryMessenger, Texture
|
||||
.checkInputConnectionProxy(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) {
|
||||
super.onProvideAutofillVirtualStructure(structure, flags);
|
||||
mTextInputPlugin.onProvideAutofillVirtualStructure(structure, flags);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void autofill(SparseArray<AutofillValue> values) {
|
||||
mTextInputPlugin.autofill(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
if (!isAttached()) {
|
||||
|
||||
@ -2,11 +2,15 @@ package io.flutter.plugin.editing;
|
||||
|
||||
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.provider.Settings;
|
||||
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 class TextInputPluginTest {
|
||||
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 class TextInputPluginTest {
|
||||
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 class TextInputPluginTest {
|
||||
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 class TextInputPluginTest {
|
||||
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 class TextInputPluginTest {
|
||||
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 class TextInputPluginTest {
|
||||
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 class TextInputPluginTest {
|
||||
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 class TextInputPluginTest {
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user