diff options
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java')
-rw-r--r-- | mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java | 1234 |
1 files changed, 1234 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java new file mode 100644 index 0000000000..5a4488f4fa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java @@ -0,0 +1,1234 @@ +/* -*- 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)); + } + } + } + } +} |