Source code

Revision control

Copy as Markdown

Other Tools

/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
* vim: ts=4 sw=4 expandtab:
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.geckoview;
import android.annotation.TargetApi;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.util.Log;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewStructure;
import android.view.autofill.AutofillValue;
import androidx.annotation.AnyThread;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.collection.ArrayMap;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.mozilla.gecko.util.BundleEventListener;
import org.mozilla.gecko.util.EventCallback;
import org.mozilla.gecko.util.GeckoBundle;
import org.mozilla.gecko.util.ThreadUtils;
public class Autofill {
private static final boolean DEBUG = false;
public @interface AutofillNotify {}
public static final class Hint {
private Hint() {}
/** Hint indicating that no special handling is required. */
public static final int NONE = -1;
/** Hint indicating that a node represents an email address. */
public static final int EMAIL_ADDRESS = 0;
/** Hint indicating that a node represents a password. */
public static final int PASSWORD = 1;
/** Hint indicating that a node represents an URI. */
public static final int URI = 2;
/** Hint indicating that a node represents a username. */
public static final int USERNAME = 3;
@AnyThread
@SuppressWarnings("checkstyle:javadocmethod")
public static @Nullable String toString(final @AutofillHint int hint) {
final int idx = hint + 1;
final String[] map = new String[] {"NONE", "EMAIL", "PASSWORD", "URI", "USERNAME"};
if (idx < 0 || idx >= map.length) {
return null;
}
return map[idx];
}
}
@Retention(RetentionPolicy.SOURCE)
@IntDef({Hint.NONE, Hint.EMAIL_ADDRESS, Hint.PASSWORD, Hint.URI, Hint.USERNAME})
public @interface AutofillHint {}
public static final class InputType {
private InputType() {}
/** Indicates that a node is not a known input type. */
public static final int NONE = -1;
/** Indicates that a node is a text input type. Example: {@code <input type="text">} */
public static final int TEXT = 0;
/** Indicates that a node is a number input type. Example: {@code <input type="number">} */
public static final int NUMBER = 1;
/** Indicates that a node is a phone input type. Example: {@code <input type="tel">} */
public static final int PHONE = 2;
@AnyThread
@SuppressWarnings("checkstyle:javadocmethod")
public static @Nullable String toString(final @AutofillInputType int type) {
final int idx = type + 1;
final String[] map = new String[] {"NONE", "TEXT", "NUMBER", "PHONE"};
if (idx < 0 || idx >= map.length) {
return null;
}
return map[idx];
}
}
@Retention(RetentionPolicy.SOURCE)
@IntDef({InputType.NONE, InputType.TEXT, InputType.NUMBER, InputType.PHONE})
public @interface AutofillInputType {}
/** Represents autofill data associated to a {@link Node}. */
public static class NodeData {
/** Autofill id for this node. */
final int id;
String value;
Node node;
EventCallback callback;
NodeData(final int id, final Node node) {
this.id = id;
this.node = node;
}
/**
* Gets the value for this node.
*
* @return a String representing the value for this node.
*/
@AnyThread
public @Nullable String getValue() {
return value;
}
/**
* Returns the autofill id for this node.
*
* @return an int representing the id for this node.
*/
@AnyThread
public int getId() {
return id;
}
}
/** Represents an autofill session. A session holds the autofill nodes and state of a page. */
public static final class Session {
private static final String LOGTAG = "AutofillSession";
private @NonNull final GeckoSession mGeckoSession;
private Node mRoot;
private HashMap<String, NodeData> mUuidToNodeData;
private SparseArray<Node> mIdToNode;
private int mCurrentIndex = 0;
private String mId = null;
// We can't store the Node directly because it might be updated by subsequent NodeAdd calls.
private String mFocusedUuid = null;
/* package */ Session(@NonNull final GeckoSession geckoSession) {
mGeckoSession = geckoSession;
// Dummy session until a real one gets created
clear(UUID.randomUUID().toString());
}
@UiThread
@SuppressWarnings("checkstyle:javadocmethod")
public @NonNull Rect getDefaultDimensions() {
final Rect rect = new Rect();
mGeckoSession.getSurfaceBounds(rect);
return rect;
}
/* package */ void clear(final String newSessionId) {
mId = newSessionId;
mFocusedUuid = null;
mRoot = Node.newDummyRoot(getDefaultDimensions(), newSessionId);
mIdToNode = new SparseArray<>();
mUuidToNodeData = new HashMap<>();
addNode(mRoot);
}
/* package */ boolean isEmpty() {
// Root data is always there
return mUuidToNodeData.size() == 1;
}
/**
* Get data for the given node.
*
* @param node the {@link Node} get data for.
* @return the {@link NodeData} for the given node.
*/
@UiThread
public @NonNull NodeData dataFor(final @NonNull Node node) {
final NodeData data = mUuidToNodeData.get(node.getUuid());
Objects.requireNonNull(data);
return data;
}
/**
* Perform auto-fill using the specified values.
*
* @param values Map of auto-fill IDs to values.
*/
@UiThread
public void autofill(@NonNull final SparseArray<CharSequence> values) {
ThreadUtils.assertOnUiThread();
if (isEmpty()) {
return;
}
final HashMap<Node, GeckoBundle> valueBundles = new HashMap<>();
for (int i = 0; i < values.size(); i++) {
final int id = values.keyAt(i);
final Node node = getNode(id);
if (node == null) {
Log.w(LOGTAG, "Could not find node id=" + id);
continue;
}
final CharSequence value = values.valueAt(i);
if (DEBUG) {
Log.d(LOGTAG, "Process autofill for id=" + id + ", value=" + value);
}
if (node == getRoot()) {
// We cannot autofill the session root as it does not correspond to a
// real element on the page.
Log.w(LOGTAG, "Ignoring autofill on session root.");
continue;
}
final Node root = node.getRoot();
if (!valueBundles.containsKey(root)) {
valueBundles.put(root, new GeckoBundle());
}
valueBundles.get(root).putString(node.getUuid(), String.valueOf(value));
}
for (final Node root : valueBundles.keySet()) {
final NodeData data = dataFor(root);
Objects.requireNonNull(data);
final EventCallback callback = data.callback;
callback.sendSuccess(valueBundles.get(root));
}
}
/* package */ void addRoot(@NonNull final Node node, final EventCallback callback) {
if (DEBUG) {
Log.d(LOGTAG, "addRoot: " + node);
}
mRoot.addChild(node);
addNode(node);
dataFor(node).callback = callback;
}
/* package */ void addNode(@NonNull final Node node) {
if (DEBUG) {
Log.d(LOGTAG, "addNode: " + node);
}
NodeData data = mUuidToNodeData.get(node.getUuid());
if (data == null) {
final int nodeId = mCurrentIndex++;
data = new NodeData(nodeId, node);
mUuidToNodeData.put(node.getUuid(), data);
} else {
data.node = node;
}
mIdToNode.put(data.id, node);
for (final Node child : node.getChildren()) {
addNode(child);
}
}
/**
* Returns true if the node is currently visible in the page.
*
* @param node the {@link Node} instance
* @return true if the node is visible, false otherwise.
*/
@UiThread
public boolean isVisible(final @NonNull Node node) {
if (!Objects.equals(node.mSessionId, mId)) {
Log.w(LOGTAG, "Requesting visibility for older session " + node.mSessionId);
return false;
}
if (mRoot == node) {
// The root is always visible
return true;
}
final Node focused = getFocused();
if (focused == null) {
return false;
}
final Node focusedRoot = focused.getRoot();
final Node focusedParent = focused.getParent();
final String parentUuid = node.getParent() != null ? node.getParent().getUuid() : null;
final String rootUuid = node.getRoot() != null ? node.getRoot().getUuid() : null;
return (focusedParent != null && focusedParent.getUuid().equals(parentUuid))
|| (focusedRoot != null && focusedRoot.getUuid().equals(rootUuid));
}
/**
* Returns the currently focused node.
*
* @return a reference to the {@link Node} that is currently focused or null if no node is
* currently focused.
*/
@UiThread
public @Nullable Node getFocused() {
return getNode(mFocusedUuid);
}
/* package */ void setFocus(final Node node) {
mFocusedUuid = node != null ? node.getUuid() : null;
}
/**
* Returns the currently focused node data.
*
* @return a refernce to {@link NodeData} or null if no node is focused.
*/
@UiThread
public @Nullable NodeData getFocusedData() {
final Node focused = getFocused();
return focused != null ? dataFor(focused) : null;
}
/* package */ @Nullable
Node getNode(final String uuid) {
if (uuid == null) {
return null;
}
final NodeData nodeData = mUuidToNodeData.get(uuid);
if (nodeData == null) {
return null;
}
return nodeData.node;
}
/* package */ Node getNode(final int id) {
return mIdToNode.get(id);
}
/**
* Get the root node of the session tree. Each session is managed in a tree with a virtual root
* node for the document.
*
* @return The root {@link Node} for this session.
*/
@AnyThread
public @NonNull Node getRoot() {
return mRoot;
}
/* package */ String getId() {
return mId;
}
@Override
@UiThread
public String toString() {
final StringBuilder builder = new StringBuilder("Session {");
final Node focused = getFocused();
builder
.append("id=")
.append(mId)
.append(", focused=")
.append(mFocusedUuid)
.append(", focusedRoot=")
.append(
(focused != null && focused.getRoot() != null) ? focused.getRoot().getUuid() : null)
.append(", root=")
.append(getRoot())
.append("}");
return builder.toString();
}
@TargetApi(23)
@UiThread
@SuppressWarnings("checkstyle:javadocmethod")
public void fillViewStructure(
@NonNull final View view, @NonNull final ViewStructure structure, final int flags) {
ThreadUtils.assertOnUiThread();
fillViewStructure(getRoot(), view, structure, flags);
}
@TargetApi(23)
@UiThread
@SuppressWarnings("checkstyle:javadocmethod")
public void fillViewStructure(
final @NonNull Node node,
@NonNull final View view,
@NonNull final ViewStructure structure,
final int flags) {
ThreadUtils.assertOnUiThread();
if (DEBUG) {
Log.d(LOGTAG, "fillViewStructure");
}
final NodeData data = dataFor(node);
if (data == null) {
return;
}
if (Build.VERSION.SDK_INT >= 26) {
structure.setAutofillId(view.getAutofillId(), data.id);
structure.setWebDomain(node.getDomain());
structure.setAutofillValue(AutofillValue.forText(data.value));
}
structure.setId(data.id, null, null, null);
// This dimensions doesn't seem to used for autofill service.
structure.setDimens(0, 0, 0, 0, node.getDimensions().width(), node.getDimensions().height());
if (Build.VERSION.SDK_INT >= 26) {
final ViewStructure.HtmlInfo.Builder htmlBuilder =
structure.newHtmlInfoBuilder(node.getTag());
for (final String key : node.getAttributes().keySet()) {
htmlBuilder.addAttribute(key, String.valueOf(node.getAttribute(key)));
}
structure.setHtmlInfo(htmlBuilder.build());
}
structure.setChildCount(node.getChildren().size());
int childCount = 0;
for (final Node child : node.getChildren()) {
final ViewStructure childStructure = structure.newChild(childCount);
fillViewStructure(child, view, childStructure, flags);
childCount++;
}
switch (node.getTag()) {
case "input":
case "textarea":
structure.setClassName("android.widget.EditText");
structure.setEnabled(node.getEnabled());
structure.setFocusable(node.getFocusable());
structure.setFocused(node.equals(getFocused()));
structure.setVisibility(isVisible(node) ? View.VISIBLE : View.INVISIBLE);
if (Build.VERSION.SDK_INT >= 26) {
structure.setAutofillType(View.AUTOFILL_TYPE_TEXT);
}
break;
default:
if (childCount > 0) {
structure.setClassName("android.view.ViewGroup");
} else {
structure.setClassName("android.view.View");
}
break;
}
if (Build.VERSION.SDK_INT < 26 || !"input".equals(node.getTag())) {
return;
}
// LastPass will fill password to the field where setAutofillHints
// is unset and setInputType is set.
switch (node.getHint()) {
case Hint.EMAIL_ADDRESS:
{
structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_EMAIL_ADDRESS});
structure.setInputType(
android.text.InputType.TYPE_CLASS_TEXT
| android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
break;
}
case Hint.PASSWORD:
{
structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PASSWORD});
structure.setInputType(
android.text.InputType.TYPE_CLASS_TEXT
| android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD);
break;
}
case Hint.URI:
{
structure.setInputType(
android.text.InputType.TYPE_CLASS_TEXT
| android.text.InputType.TYPE_TEXT_VARIATION_URI);
break;
}
case Hint.USERNAME:
{
structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_USERNAME});
structure.setInputType(
android.text.InputType.TYPE_CLASS_TEXT
| android.text.InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
break;
}
case Hint.NONE:
{
// Nothing to do.
break;
}
}
switch (node.getInputType()) {
case InputType.NUMBER:
{
structure.setInputType(android.text.InputType.TYPE_CLASS_NUMBER);
break;
}
case InputType.PHONE:
{
structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PHONE});
structure.setInputType(android.text.InputType.TYPE_CLASS_PHONE);
break;
}
case InputType.TEXT:
case InputType.NONE:
// Nothing to do.
break;
}
}
}
/**
* Represents an autofill node. A node is an input element and may contain child nodes forming a
* tree.
*/
public static final class Node {
private final String mUuid;
private final Node mRoot;
private final Node mParent;
private final @NonNull Rect mDimens;
private final @NonNull Rect mScreenRect;
private final @NonNull Map<String, Node> mChildren;
private final @NonNull Map<String, String> mAttributes;
private final boolean mEnabled;
private final boolean mFocusable;
private final @AutofillHint int mHint;
private final @AutofillInputType int mInputType;
private final @NonNull String mTag;
private final @NonNull String mDomain;
private final String mSessionId;
/* package */
@NonNull
String getUuid() {
return mUuid;
}
/* package */
@Nullable
Node getRoot() {
return mRoot;
}
/* package */
@Nullable
Node getParent() {
return mParent;
}
/**
* Get the dimensions of this node in CSS coordinates. Note: Invisible nodes will report their
* proper dimensions.
*
* @return The dimensions of this node.
*/
@AnyThread
/* package */ @NonNull
Rect getDimensions() {
return mDimens;
}
/**
* Get the dimensions of this node in screen coordinates. This is valid when this node has an
* focus.
*
* @return The dimensions of this node.
*/
@AnyThread
public @NonNull Rect getScreenRect() {
return mScreenRect;
}
/**
* Set the dimensions of this node in screen coordinates.
*
* @param screenRect The dimensions of this node.
*/
/* package */ void setScreenRect(final @NonNull RectF screenRectF) {
screenRectF.roundOut(mScreenRect);
}
/**
* Get the child nodes for this node.
*
* @return The collection of child nodes for this node.
*/
@AnyThread
public @NonNull Collection<Node> getChildren() {
return mChildren.values();
}
/* package */
@NonNull
Node addChild(@NonNull final Node child) {
mChildren.put(child.getUuid(), child);
return this;
}
/**
* Get HTML attributes for this node.
*
* @return The HTML attributes for this node.
*/
@AnyThread
public @NonNull Map<String, String> getAttributes() {
return mAttributes;
}
@AnyThread
@SuppressWarnings("checkstyle:javadocmethod")
public @Nullable String getAttribute(@NonNull final String key) {
return mAttributes.get(key);
}
/**
* Get whether or not this node is enabled.
*
* @return True if the node is enabled, false otherwise.
*/
@AnyThread
public boolean getEnabled() {
return mEnabled;
}
/**
* Get whether or not this node is focusable.
*
* @return True if the node is focusable, false otherwise.
*/
@AnyThread
public boolean getFocusable() {
return mFocusable;
}
/**
* Get the hint for the type of data contained in this node.
*
* @return The input data hint for this node, one of {@link Hint}.
*/
@AnyThread
public @AutofillHint int getHint() {
return mHint;
}
/**
* Get the input type of this node.
*
* @return The input type of this node, one of {@link InputType}.
*/
@AnyThread
public @AutofillInputType int getInputType() {
return mInputType;
}
/**
* Get the HTML tag of this node.
*
* @return The HTML tag of this node.
*/
@AnyThread
public @NonNull String getTag() {
return mTag;
}
/**
* Get web domain of this node.
*
* @return The domain of this node.
*/
@AnyThread
public @NonNull String getDomain() {
return mDomain;
}
/* package */
static Node newDummyRoot(final Rect dimensions, final String sessionId) {
return new Node(dimensions, sessionId);
}
/* package */ Node(final Rect dimensions, final String sessionId) {
mRoot = null;
mParent = null;
mUuid = UUID.randomUUID().toString();
mDimens = dimensions;
mScreenRect = new Rect();
mSessionId = sessionId;
mAttributes = new ArrayMap<>();
mEnabled = false;
mFocusable = false;
mHint = Hint.NONE;
mInputType = InputType.NONE;
mTag = "";
mDomain = "";
mChildren = new HashMap<>();
}
@Override
@AnyThread
public String toString() {
final StringBuilder builder = new StringBuilder("Node {");
builder
.append("uuid=")
.append(mUuid)
.append(", sessionId=")
.append(mSessionId)
.append(", parent=")
.append(mParent != null ? mParent.getUuid() : null)
.append(", root=")
.append(mRoot != null ? mRoot.getUuid() : null)
.append(", dims=")
.append(getDimensions().toShortString())
.append(", screenRect=")
.append(getScreenRect().toShortString())
.append(", children=[");
for (final Node child : mChildren.values()) {
builder.append(child.getUuid()).append(", ");
}
builder
.append("]")
.append(", attrs=")
.append(mAttributes)
.append(", enabled=")
.append(mEnabled)
.append(", focusable=")
.append(mFocusable)
.append(", hint=")
.append(Hint.toString(mHint))
.append(", type=")
.append(InputType.toString(mInputType))
.append(", tag=")
.append(mTag)
.append(", domain=")
.append(mDomain)
.append("}");
return builder.toString();
}
/* package */ Node(
@NonNull final GeckoBundle bundle, final Rect defaultDimensions, final String sessionId) {
this(bundle, /* root */ null, /* parent */ null, defaultDimensions, sessionId);
}
/* package */ Node(
@NonNull final GeckoBundle bundle,
final Node root,
final Node parent,
final Rect defaultDimensions,
final String sessionId) {
final GeckoBundle bounds = bundle.getBundle("bounds");
mSessionId = sessionId;
mUuid = bundle.getString("uuid");
mDomain = bundle.getString("origin", "");
final Rect dimens =
new Rect(
bounds.getInt("left"),
bounds.getInt("top"),
bounds.getInt("right"),
bounds.getInt("bottom"));
if (dimens.isEmpty()) {
// Some nodes like <html> will have null-dimensions,
// we need to set them to the virtual documents dimensions.
mDimens = defaultDimensions;
} else {
mDimens = dimens;
}
mScreenRect = new Rect();
mParent = parent;
// If the root is null, then this object is the root itself
mRoot = root != null ? root : this;
final GeckoBundle[] children = bundle.getBundleArray("children");
final Map<String, Node> childrenMap = new HashMap<>(children != null ? children.length : 0);
if (children != null) {
for (final GeckoBundle childBundle : children) {
final Node child = new Node(childBundle, mRoot, this, defaultDimensions, sessionId);
childrenMap.put(child.getUuid(), child);
}
}
mChildren = childrenMap;
mTag = bundle.getString("tag", "").toLowerCase(Locale.ROOT);
final GeckoBundle attrs = bundle.getBundle("attributes");
final Map<String, String> attributes = new HashMap<>();
for (final String key : attrs.keys()) {
attributes.put(key, String.valueOf(attrs.get(key)));
}
mAttributes = attributes;
mEnabled =
enabledFromBundle(
mTag, bundle.getBoolean("editable", false), bundle.getBoolean("disabled", false));
mFocusable = mEnabled;
final String type = bundle.getString("type", "text").toLowerCase(Locale.ROOT);
final String hint = bundle.getString("autofillhint", "").toLowerCase(Locale.ROOT);
mInputType = typeFromBundle(type, hint);
mHint = hintFromBundle(type, hint);
}
private boolean enabledFromBundle(
final String tag, final boolean editable, final boolean disabled) {
switch (tag) {
case "input":
{
if (!editable) {
// Don't process non-editable inputs (e.g., type="button").
return false;
}
return !disabled;
}
case "textarea":
return !disabled;
default:
return false;
}
}
private @AutofillHint int hintFromBundle(final String type, final String hint) {
switch (type) {
case "email":
return Hint.EMAIL_ADDRESS;
case "password":
return Hint.PASSWORD;
case "url":
return Hint.URI;
case "text":
{
if (hint.equals("username")) {
return Hint.USERNAME;
}
break;
}
}
return Hint.NONE;
}
private @AutofillInputType int typeFromBundle(final String type, final String hint) {
switch (type) {
case "password":
case "url":
case "email":
return InputType.TEXT;
case "number":
return InputType.NUMBER;
case "tel":
return InputType.PHONE;
case "text":
{
if (hint.equals("username")) {
return InputType.TEXT;
}
break;
}
}
return InputType.NONE;
}
}
public interface Delegate {
/**
* An autofill session has started. Usually triggered by page load.
*
* @param session The {@link GeckoSession} instance.
*/
@UiThread
default void onSessionStart(@NonNull final GeckoSession session) {}
/**
* An autofill session has been committed. Triggered by form submission or navigation.
*
* @param session The {@link GeckoSession} instance.
* @param node the node that is being committed.
* @param data the node data associated to the node being committed.
*/
@UiThread
default void onSessionCommit(
@NonNull final GeckoSession session,
@NonNull final Node node,
@NonNull final NodeData data) {}
/**
* An autofill session has been canceled. Triggered by page unload.
*
* @param session The {@link GeckoSession} instance.
*/
@UiThread
default void onSessionCancel(@NonNull final GeckoSession session) {}
/**
* A node within the autofill session has been added.
*
* @param session The {@link GeckoSession} instance.
* @param node The {@link Node} that was added.
* @param data The {@link NodeData} associated to the note that was added.
*/
@UiThread
default void onNodeAdd(
@NonNull final GeckoSession session,
@NonNull final Node node,
@NonNull final NodeData data) {}
/**
* A node within the autofill session has been removed.
*
* @param session The {@link GeckoSession} instance.
* @param node The {@link Node} that was removed.
* @param data The {@link NodeData} associated to the note that was removed.
*/
@UiThread
default void onNodeRemove(
@NonNull final GeckoSession session,
@NonNull final Node node,
@NonNull final NodeData data) {}
/**
* A node within the autofill session has been updated.
*
* @param session The {@link GeckoSession} instance.
* @param node The {@link Node} that was updated.
* @param data The {@link NodeData} associated to the note that was updated.
*/
@UiThread
default void onNodeUpdate(
@NonNull final GeckoSession session,
@NonNull final Node node,
@NonNull final NodeData data) {}
/**
* A node within the autofill session has gained focus.
*
* @param session The {@link GeckoSession} instance.
* @param focused The {@link Node} that is now focused.
* @param data The {@link NodeData} associated to the note that is now focused.
*/
@UiThread
default void onNodeFocus(
@NonNull final GeckoSession session,
@NonNull final Node focused,
@NonNull final NodeData data) {}
/**
* A node within the autofill session has lost focus.
*
* @param session The {@link GeckoSession} instance.
* @param prev The {@link Node} that lost focus.
* @param data The {@link NodeData} associated to the note that lost focus.
*/
@UiThread
default void onNodeBlur(
@NonNull final GeckoSession session,
@NonNull final Node prev,
@NonNull final NodeData data) {}
}
/* package */ static final class Support implements BundleEventListener {
private static final String LOGTAG = "AutofillSupport";
private @NonNull final GeckoSession mGeckoSession;
private @NonNull final Session mAutofillSession;
private Delegate mDelegate;
public Support(@NonNull final GeckoSession geckoSession) {
mGeckoSession = geckoSession;
mAutofillSession = new Session(mGeckoSession);
}
public void registerListeners() {
mGeckoSession
.getEventDispatcher()
.registerUiThreadListener(
this,
"GeckoView:StartAutofill",
"GeckoView:AddAutofill",
"GeckoView:ClearAutofill",
"GeckoView:CommitAutofill",
"GeckoView:OnAutofillFocus",
"GeckoView:UpdateAutofill");
}
@Override
public void handleMessage(
final String event, final GeckoBundle message, final EventCallback callback) {
Log.d(LOGTAG, "handleMessage " + event);
if ("GeckoView:AddAutofill".equals(event)) {
addNode(message.getBundle("node"), callback);
} else if ("GeckoView:StartAutofill".equals(event)) {
start(message.getString("sessionId"));
} else if ("GeckoView:ClearAutofill".equals(event)) {
clear();
} else if ("GeckoView:OnAutofillFocus".equals(event)) {
onFocusChanged(message.getBundle("node"));
} else if ("GeckoView:CommitAutofill".equals(event)) {
commit(message.getBundle("node"));
} else if ("GeckoView:UpdateAutofill".equals(event)) {
update(message.getBundle("node"));
}
}
@UiThread
public void setDelegate(final @Nullable Delegate delegate) {
ThreadUtils.assertOnUiThread();
mDelegate = delegate;
}
@UiThread
public @Nullable Delegate getDelegate() {
ThreadUtils.assertOnUiThread();
return mDelegate;
}
@UiThread
public @NonNull Session getAutofillSession() {
ThreadUtils.assertOnUiThread();
return mAutofillSession;
}
/* package */ void addNode(
@NonNull final GeckoBundle message, @NonNull final EventCallback callback) {
final Session session = getAutofillSession();
final Node node = new Node(message, session.getDefaultDimensions(), session.getId());
session.addRoot(node, callback);
addValues(message);
if (mDelegate != null) {
mDelegate.onNodeAdd(mGeckoSession, node, getAutofillSession().dataFor(node));
}
}
private void addValues(final GeckoBundle message) {
final String uuid = message.getString("uuid");
if (uuid == null) {
return;
}
final String value = message.getString("value");
final Node node = getAutofillSession().getNode(uuid);
if (node == null) {
Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
return;
}
Objects.requireNonNull(node);
final NodeData data = getAutofillSession().dataFor(node);
Objects.requireNonNull(data);
data.value = value;
final GeckoBundle[] children = message.getBundleArray("children");
if (children != null) {
for (final GeckoBundle child : children) {
addValues(child);
}
}
}
/* package */ void start(@Nullable final String sessionId) {
// Make sure we start with a clean session
getAutofillSession().clear(sessionId);
if (mDelegate != null) {
mDelegate.onSessionStart(mGeckoSession);
}
}
/* package */ void commit(@Nullable final GeckoBundle message) {
if (getAutofillSession().isEmpty() || message == null) {
return;
}
final String uuid = message.getString("uuid");
final Node node = getAutofillSession().getNode(uuid);
if (node == null) {
Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
return;
}
if (DEBUG) {
Log.d(LOGTAG, "commit(" + uuid + ")");
}
if (mDelegate != null) {
mDelegate.onSessionCommit(mGeckoSession, node, getAutofillSession().dataFor(node));
}
}
/* package */ void update(@Nullable final GeckoBundle message) {
if (getAutofillSession().isEmpty() || message == null) {
return;
}
final String uuid = message.getString("uuid");
if (DEBUG) {
Log.d(LOGTAG, "update(" + uuid + ")");
}
final Node node = getAutofillSession().getNode(uuid);
final String value = message.getString("value", "");
if (node == null) {
Log.d(LOGTAG, "could not find node " + uuid);
return;
}
if (DEBUG) {
final NodeData data = getAutofillSession().dataFor(node);
Log.d(
LOGTAG,
"updating node " + uuid + " value from " + data != null
? data.value
: null + " to " + value);
}
getAutofillSession().dataFor(node).value = value;
if (mDelegate != null) {
mDelegate.onNodeUpdate(mGeckoSession, node, getAutofillSession().dataFor(node));
}
}
/* package */ void clear() {
if (getAutofillSession().isEmpty()) {
return;
}
if (DEBUG) {
Log.d(LOGTAG, "clear()");
}
getAutofillSession().clear(null);
if (mDelegate != null) {
mDelegate.onSessionCancel(mGeckoSession);
}
}
/* package */ void onFocusChanged(@Nullable final GeckoBundle message) {
final Session session = getAutofillSession();
if (session.isEmpty()) {
return;
}
final Node prev = getAutofillSession().getFocused();
final String prevUuid = prev != null ? prev.getUuid() : null;
final String uuid = message != null ? message.getString("uuid") : null;
final Node focused;
if (uuid == null) {
focused = null;
} else {
focused = session.getNode(uuid);
if (focused == null) {
Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
return;
}
if (message != null) {
final RectF screenRectF = message.getRectF("screenRect");
focused.setScreenRect(screenRectF);
}
}
if (DEBUG) {
Log.d(
LOGTAG,
"onFocusChanged(" + (prev != null ? prev.getUuid() : null) + " -> " + uuid + ')');
}
if (Objects.equals(uuid, prevUuid)) {
// Nothing changed, nothing to do.
return;
}
session.setFocus(focused);
if (mDelegate != null) {
if (prev != null) {
mDelegate.onNodeBlur(mGeckoSession, prev, getAutofillSession().dataFor(prev));
}
if (uuid != null) {
mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
}
}
}
@UiThread
public void onActiveChanged(final boolean active) {
ThreadUtils.assertOnUiThread();
final Node focused = getAutofillSession().getFocused();
if (focused == null) {
return;
}
if (mDelegate != null) {
if (active) {
mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
} else {
mDelegate.onNodeBlur(mGeckoSession, focused, getAutofillSession().dataFor(focused));
}
}
}
}
}