blob: b206e5aa2e36aa18e8e25f4852c04aae0c26e2a1 [file] [log] [blame]
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;
import io.flutter.Log;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.plugin.common.JSONMethodCodec;
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;
/**
* {@link TextInputChannel} is a platform channel between Android and Flutter that is used to
* communicate information about the user's text input.
*
* <p>When the user presses an action button like "done" or "next", that action is sent from Android
* to Flutter through this {@link TextInputChannel}.
*
* <p>When an input system in the Flutter app wants to show the keyboard, or hide it, or configure
* editing state, etc. a message is sent from Flutter to Android through this {@link
* TextInputChannel}.
*
* <p>{@link TextInputChannel} comes with a default {@link
* io.flutter.plugin.common.MethodChannel.MethodCallHandler} that parses incoming messages from
* Flutter. Register a {@link TextInputMethodHandler} to respond to standard Flutter text input
* messages.
*/
public class TextInputChannel {
private static final String TAG = "TextInputChannel";
@NonNull public final MethodChannel channel;
@Nullable private TextInputMethodHandler textInputMethodHandler;
private final MethodChannel.MethodCallHandler parsingMethodHandler =
new MethodChannel.MethodCallHandler() {
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if (textInputMethodHandler == null) {
// If no explicit TextInputMethodHandler has been registered then we don't
// need to forward this call to an API. Return.
return;
}
String method = call.method;
Object args = call.arguments;
Log.v(TAG, "Received '" + method + "' message.");
switch (method) {
case "TextInput.show":
textInputMethodHandler.show();
result.success(null);
break;
case "TextInput.hide":
textInputMethodHandler.hide();
result.success(null);
break;
case "TextInput.setClient":
try {
final JSONArray argumentList = (JSONArray) args;
final int textInputClientId = argumentList.getInt(0);
final JSONObject jsonConfiguration = argumentList.getJSONObject(1);
textInputMethodHandler.setClient(
textInputClientId, Configuration.fromJson(jsonConfiguration));
result.success(null);
} catch (JSONException | NoSuchFieldException exception) {
// JSONException: missing keys or bad value types.
// NoSuchFieldException: one or more values were invalid.
result.error("error", exception.getMessage(), null);
}
break;
case "TextInput.requestAutofill":
textInputMethodHandler.requestAutofill();
result.success(null);
break;
case "TextInput.setPlatformViewClient":
final int id = (int) args;
textInputMethodHandler.setPlatformViewClient(id);
break;
case "TextInput.setEditingState":
try {
final JSONObject editingState = (JSONObject) args;
textInputMethodHandler.setEditingState(TextEditState.fromJson(editingState));
result.success(null);
} catch (JSONException exception) {
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);
break;
default:
result.notImplemented();
break;
}
}
};
/**
* Constructs a {@code TextInputChannel} that connects Android to the Dart code running in {@code
* dartExecutor}.
*
* <p>The given {@code dartExecutor} is permitted to be idle or executing code.
*
* <p>See {@link DartExecutor}.
*/
public TextInputChannel(@NonNull DartExecutor dartExecutor) {
this.channel = new MethodChannel(dartExecutor, "flutter/textinput", JSONMethodCodec.INSTANCE);
channel.setMethodCallHandler(parsingMethodHandler);
}
/**
* Instructs Flutter to reattach the last active text input client, if any.
*
* <p>This is necessary when the view heirarchy has been detached and reattached to a {@link
* FlutterEngine}, as the engine may have kept alive a text editing client on the Dart side.
*/
public void requestExistingInputState() {
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.
*/
public void updateEditingState(
int inputClientId,
String text,
int selectionStart,
int selectionEnd,
int composingStart,
int composingEnd) {
Log.v(
TAG,
"Sending message to update editing state: \n"
+ "Text: "
+ text
+ "\n"
+ "Selection start: "
+ selectionStart
+ "\n"
+ "Selection end: "
+ selectionEnd
+ "\n"
+ "Composing start: "
+ composingStart
+ "\n"
+ "Composing end: "
+ 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.");
channel.invokeMethod(
"TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.newline"));
}
/** Instructs Flutter to execute a "go" action. */
public void go(int inputClientId) {
Log.v(TAG, "Sending 'go' message.");
channel.invokeMethod(
"TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.go"));
}
/** Instructs Flutter to execute a "search" action. */
public void search(int inputClientId) {
Log.v(TAG, "Sending 'search' message.");
channel.invokeMethod(
"TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.search"));
}
/** Instructs Flutter to execute a "send" action. */
public void send(int inputClientId) {
Log.v(TAG, "Sending 'send' message.");
channel.invokeMethod(
"TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.send"));
}
/** Instructs Flutter to execute a "done" action. */
public void done(int inputClientId) {
Log.v(TAG, "Sending 'done' message.");
channel.invokeMethod(
"TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.done"));
}
/** Instructs Flutter to execute a "next" action. */
public void next(int inputClientId) {
Log.v(TAG, "Sending 'next' message.");
channel.invokeMethod(
"TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.next"));
}
/** Instructs Flutter to execute a "previous" action. */
public void previous(int inputClientId) {
Log.v(TAG, "Sending 'previous' message.");
channel.invokeMethod(
"TextInputClient.performAction", Arrays.asList(inputClientId, "TextInputAction.previous"));
}
/** Instructs Flutter to execute an "unspecified" action. */
public void unspecifiedAction(int inputClientId) {
Log.v(TAG, "Sending 'unspecified' message.");
channel.invokeMethod(
"TextInputClient.performAction",
Arrays.asList(inputClientId, "TextInputAction.unspecified"));
}
/**
* Sets the {@link TextInputMethodHandler} which receives all events and requests that are parsed
* from the underlying platform channel.
*/
public void setTextInputMethodHandler(@Nullable TextInputMethodHandler textInputMethodHandler) {
this.textInputMethodHandler = textInputMethodHandler;
}
public interface TextInputMethodHandler {
// TODO(mattcarroll): javadoc
void show();
// 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);
/**
* Sets a platform view as the text input client.
*
* <p>Subsequent calls to createInputConnection will be delegated to the platform view until a
* different client is set.
*
* @param id the ID of the platform view to be set as a text input client.
*/
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);
// TODO(mattcarroll): javadoc
void clearClient();
}
/** A text editing configuration. */
public static class Configuration {
public static Configuration fromJson(@NonNull JSONObject json)
throws JSONException, NoSuchFieldException {
final String inputActionName = json.getString("inputAction");
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"),
json.optBoolean("autocorrect", true),
json.optBoolean("enableSuggestions"),
TextCapitalization.fromValue(json.getString("textCapitalization")),
InputType.fromJson(json.getJSONObject("inputType")),
inputAction,
json.isNull("actionLabel") ? null : json.getString("actionLabel"),
json.isNull("autofill") ? null : Autofill.fromJson(json.getJSONObject("autofill")),
fields);
}
@NonNull
private static Integer inputActionFromTextInputAction(@NonNull String inputAction) {
switch (inputAction) {
case "TextInputAction.newline":
return EditorInfo.IME_ACTION_NONE;
case "TextInputAction.none":
return EditorInfo.IME_ACTION_NONE;
case "TextInputAction.unspecified":
return EditorInfo.IME_ACTION_UNSPECIFIED;
case "TextInputAction.done":
return EditorInfo.IME_ACTION_DONE;
case "TextInputAction.go":
return EditorInfo.IME_ACTION_GO;
case "TextInputAction.search":
return EditorInfo.IME_ACTION_SEARCH;
case "TextInputAction.send":
return EditorInfo.IME_ACTION_SEND;
case "TextInputAction.next":
return EditorInfo.IME_ACTION_NEXT;
case "TextInputAction.previous":
return EditorInfo.IME_ACTION_PREVIOUS;
default:
// Present default key if bad input type is given.
return EditorInfo.IME_ACTION_UNSPECIFIED;
}
}
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;
@NonNull public final TextCapitalization textCapitalization;
@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,
boolean autocorrect,
boolean enableSuggestions,
@NonNull TextCapitalization textCapitalization,
@NonNull InputType inputType,
@Nullable Integer inputAction,
@Nullable String actionLabel,
@Nullable Autofill autofill,
@Nullable Configuration[] fields) {
this.obscureText = obscureText;
this.autocorrect = autocorrect;
this.enableSuggestions = enableSuggestions;
this.textCapitalization = textCapitalization;
this.inputType = inputType;
this.inputAction = inputAction;
this.actionLabel = actionLabel;
this.autofill = autofill;
this.fields = fields;
}
}
/**
* A text input type.
*
* <p>If the {@link #type} is {@link TextInputType#NUMBER}, this {@code InputType} also reports
* whether that number {@link #isSigned} and {@link #isDecimal}.
*/
public static class InputType {
@NonNull
public static InputType fromJson(@NonNull JSONObject json)
throws JSONException, NoSuchFieldException {
return new InputType(
TextInputType.fromValue(json.getString("name")),
json.optBoolean("signed", false),
json.optBoolean("decimal", false));
}
@NonNull public final TextInputType type;
public final boolean isSigned;
public final boolean isDecimal;
public InputType(@NonNull TextInputType type, boolean isSigned, boolean isDecimal) {
this.type = type;
this.isSigned = isSigned;
this.isDecimal = isDecimal;
}
}
/** Types of text input. */
public enum TextInputType {
TEXT("TextInputType.text"),
DATETIME("TextInputType.datetime"),
NAME("TextInputType.name"),
POSTAL_ADDRESS("TextInputType.address"),
NUMBER("TextInputType.number"),
PHONE("TextInputType.phone"),
MULTILINE("TextInputType.multiline"),
EMAIL_ADDRESS("TextInputType.emailAddress"),
URL("TextInputType.url"),
VISIBLE_PASSWORD("TextInputType.visiblePassword");
static TextInputType fromValue(@NonNull String encodedName) throws NoSuchFieldException {
for (TextInputType textInputType : TextInputType.values()) {
if (textInputType.encodedName.equals(encodedName)) {
return textInputType;
}
}
throw new NoSuchFieldException("No such TextInputType: " + encodedName);
}
@NonNull private final String encodedName;
TextInputType(@NonNull String encodedName) {
this.encodedName = encodedName;
}
}
/** Text capitalization schemes. */
public enum TextCapitalization {
CHARACTERS("TextCapitalization.characters"),
WORDS("TextCapitalization.words"),
SENTENCES("TextCapitalization.sentences"),
NONE("TextCapitalization.none");
static TextCapitalization fromValue(@NonNull String encodedName) throws NoSuchFieldException {
for (TextCapitalization textCapitalization : TextCapitalization.values()) {
if (textCapitalization.encodedName.equals(encodedName)) {
return textCapitalization;
}
}
throw new NoSuchFieldException("No such TextCapitalization: " + encodedName);
}
@NonNull private final String encodedName;
TextCapitalization(@NonNull String encodedName) {
this.encodedName = encodedName;
}
}
/** State of an on-going text editing session. */
public static class TextEditState {
public static TextEditState fromJson(@NonNull JSONObject textEditState) throws JSONException {
return new TextEditState(
textEditState.getString("text"),
textEditState.getInt("selectionBase"),
textEditState.getInt("selectionExtent"));
}
@NonNull public final String text;
public final int selectionStart;
public final int selectionEnd;
public TextEditState(@NonNull String text, int selectionStart, int selectionEnd) {
this.text = text;
this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;
}
}
}