ByMatcher.java

/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.test.uiautomator;

import android.os.SystemClock;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * A utility class which provides static methods for searching the {@link AccessibilityNodeInfo}
 * hierarchy for nodes that match {@link BySelector} criteria.
 */
class ByMatcher {

    private static final String TAG = ByMatcher.class.getSimpleName();

    private UiDevice mDevice;
    private BySelector mSelector;
    private boolean mShortCircuit;

    /**
     * Constructs a new {@link ByMatcher} instance. Used by
     * {@link ByMatcher#findMatch(UiDevice, BySelector, AccessibilityNodeInfo...)} to store state information
     * that does not change during recursive calls.
     *
     * @param selector The criteria used to determine if a {@link AccessibilityNodeInfo} is a match.
     * @param shortCircuit If true, this method will return early when the first match is found.
     */
    private ByMatcher(UiDevice device, BySelector selector, boolean shortCircuit) {
        mDevice = device;
        mSelector = selector;
        mShortCircuit = shortCircuit;
    }

    /**
     * Traverses the {@link AccessibilityNodeInfo} hierarchy starting at each {@code root} and
     * returns the first node to match the {@code selector} criteria. <br />
     * <strong>Note:</strong> The caller must release the {@link AccessibilityNodeInfo} instance by
     * calling {@link AccessibilityNodeInfo#recycle()} to avoid leaking resources.
     *
     * @param device A reference to the {@link UiDevice}.
     * @param selector The {@link BySelector} criteria used to determine if a node is a match.
     * @return The first {@link AccessibilityNodeInfo} which matched the search criteria.
     */
    static AccessibilityNodeInfo findMatch(UiDevice device, BySelector selector,
            AccessibilityNodeInfo... roots) {

        // TODO: Don't short-circuit when debugging, and warn if more than one match.
        ByMatcher matcher = new ByMatcher(device, selector, true);
        for (AccessibilityNodeInfo root : roots) {
            List<AccessibilityNodeInfo> matches = matcher.findMatches(root);
            if (!matches.isEmpty()) {
                return matches.get(0);
            }
        }
        return null;
    }

    /**
     * Traverses the {@link AccessibilityNodeInfo} hierarchy starting at each {@code root} and
     * returns a list of nodes which match the {@code selector} criteria. <br />
     * <strong>Note:</strong> The caller must release each {@link AccessibilityNodeInfo} instance
     * by calling {@link AccessibilityNodeInfo#recycle()} to avoid leaking resources.
     *
     * @param device A reference to the {@link UiDevice}.
     * @param selector The {@link BySelector} criteria used to determine if a node is a match.
     * @return A list containing all of the nodes which matched the search criteria.
     */
    static List<AccessibilityNodeInfo> findMatches(UiDevice device, BySelector selector,
            AccessibilityNodeInfo... roots) {

        List<AccessibilityNodeInfo> ret = new ArrayList<AccessibilityNodeInfo>();
        ByMatcher matcher = new ByMatcher(device, selector, false);
        for (AccessibilityNodeInfo root : roots) {
            ret.addAll(matcher.findMatches(root));
        }
        return ret;
    }

    /**
     * Traverses the {@link AccessibilityNodeInfo} hierarchy starting at {@code root}, and returns
     * a list of nodes which match the {@code selector} criteria. <br />
     * <strong>Note:</strong> The caller must release each {@link AccessibilityNodeInfo} instance
     * by calling {@link AccessibilityNodeInfo#recycle()} to avoid leaking resources.
     *
     * @param root The root {@link AccessibilityNodeInfo} from which to start the search.
     * @return A list containing all of the nodes which matched the search criteria.
     */
    private List<AccessibilityNodeInfo> findMatches(AccessibilityNodeInfo root) {
        List<AccessibilityNodeInfo> ret =
                findMatches(root, 0, 0, new SinglyLinkedList<PartialMatch>());

        // If no matches were found
        if (ret.isEmpty()) {
            // Run watchers and retry
            mDevice.runWatchers();
            ret = findMatches(root, 0, 0, new SinglyLinkedList<PartialMatch>());
        }

        return ret;
    }

    /**
     * Traverses the {@link AccessibilityNodeInfo} hierarchy starting at {@code node}, and returns
     * a list of nodes which match the {@code selector} criteria. <br />
     * <strong>Note:</strong> The caller must release each {@link AccessibilityNodeInfo} instance
     * by calling {@link AccessibilityNodeInfo#recycle()} to avoid leaking resources.
     *
     * @param node The root of the {@link AccessibilityNodeInfo} subtree we are currently searching.
     * @param index The index of this node underneath its parent.
     * @param depth The distance between {@code node} and the root node.
     * @param partialMatches The current list of {@link PartialMatch}es that need to be updated.
     * @return A {@link List} of {@link AccessibilityNodeInfo}s that meet the search criteria.
     */
    private List<AccessibilityNodeInfo> findMatches(AccessibilityNodeInfo node,
            int index, int depth, SinglyLinkedList<PartialMatch> partialMatches) {

        List<AccessibilityNodeInfo> ret = new ArrayList<AccessibilityNodeInfo>();

        // Don't bother searching the subtree if it is not visible
        if (!node.isVisibleToUser()) {
            return ret;
        }

        // Update partial matches
        for (PartialMatch partialMatch : partialMatches) {
            partialMatches = partialMatch.update(node, index, depth, partialMatches);
        }

        // Create a new match, if necessary
        PartialMatch currentMatch = PartialMatch.accept(node, mSelector, index, depth);
        if (currentMatch != null) {
            partialMatches = SinglyLinkedList.prepend(currentMatch, partialMatches);
        }

        // For each child
        int numChildren = node.getChildCount();
        boolean hasNullChild = false;
        for (int i = 0; i < numChildren; i++) {
            AccessibilityNodeInfo child = node.getChild(i);
            if (child == null) {
                if (!hasNullChild) {
                    Log.w(TAG, String.format("Node returned null child: %s", node.toString()));
                }
                hasNullChild = true;
                Log.w(TAG, String.format("Skipping null child (%s of %s)", i, numChildren));
                continue;
            }

            // Add any matches found under the child subtree
            ret.addAll(findMatches(child, i, depth + 1, partialMatches));

            // We're done with the child
            child.recycle();

            // Return early if we sound a match and shortCircuit is true
            if (!ret.isEmpty() && mShortCircuit) {
                return ret;
            }
        }

        // Finalize match, if necessary
        if (currentMatch != null && currentMatch.finalizeMatch()) {
            ret.add(AccessibilityNodeInfo.obtain(node));
        }

        return ret;
    }

    /** Helper method used to evaluate a {@link Pattern} criteria if it is set. */
    static private boolean checkCriteria(Pattern criteria, CharSequence value) {
        if (criteria == null) {
            return true;
        }
        return criteria.matcher(value != null ? value : "").matches();
    }

    /** Helper method used to evaluate a {@link Boolean} criteria if it is set. */
    static private boolean checkCriteria(Boolean criteria, boolean value) {
        if (criteria == null) {
            return true;
        }
        return criteria.equals(value);
    }

    /**
     * A {@link PartialMatch} instance represents a potential match against the given
     * {@link BySelector}. Attributes of the current node itself have been evaluated, but any child
     * selectors have not, since we must first visit the subtree under the node before we can tell
     * if the child selectors were matched.
     */
    static private class PartialMatch {
        private final int matchDepth;
        private final BySelector matchSelector;
        private final List<PartialMatch> partialMatches = new ArrayList<PartialMatch>();

        /**
         * Private constructor. Should be instanciated by calling the
         * {@link PartialMatch#accept(AccessibilityNodeInfo, BySelector, int, int)} factory method.
         */
        private PartialMatch(BySelector selector, int depth) {
            matchSelector = selector;
            matchDepth = depth;
        }

        /**
         * Factory method which returns a new {@link PartialMatch} if the node partially matches
         * the {@code selector}, otherwise returns null.
         *
         * @param node The node to check.
         * @param selector The criteria used to evaluate the node.
         * @param index The index of this node underneath its parent.
         * @param depth The distance between {@code node} and the root node.
         * @return A {@link PartialMatch} instance if the node matches all non-child selector
         * criteria, otherwise null.
         */
        public static PartialMatch accept(AccessibilityNodeInfo node, BySelector selector,
                int index, int depth) {
            return accept(node, selector, index, depth, depth);
        }

        /**
         * Factory method which returns a new {@link PartialMatch} if the node partially matches
         * the {@code selector}, otherwise returns null.
         *
         * @param node The node to check.
         * @param selector The criteria used to evaluate the node.
         * @param index The index of this node underneath its parent.
         * @param absoluteDepth The distance between {@code node} and the root node.
         * @param relativeDepth The distance between {@code node} and the matching ancestor.
         * @return A {@link PartialMatch} instance if the node matches all non-child selector
         * criteria, otherwise null.
         */
        public static PartialMatch accept(AccessibilityNodeInfo node, BySelector selector,
                int index, int absoluteDepth, int relativeDepth) {

            if ((selector.mMinDepth != null && relativeDepth < selector.mMinDepth) ||
                    (selector.mMaxDepth != null && relativeDepth > selector.mMaxDepth)) {
                return null;
            }

            // NB: index is not checked, as it is not a BySelector criteria (yet). Keeping the
            // parameter in place in case matching on index is really needed.

            PartialMatch ret = null;
            if (checkCriteria(selector.mClazz, node.getClassName()) &&
                    checkCriteria(selector.mDesc, node.getContentDescription()) &&
                    checkCriteria(selector.mPkg, node.getPackageName()) &&
                    checkCriteria(selector.mRes, node.getViewIdResourceName()) &&
                    checkCriteria(selector.mText, node.getText()) &&
                    checkCriteria(selector.mChecked, node.isChecked()) &&
                    checkCriteria(selector.mCheckable, node.isCheckable()) &&
                    checkCriteria(selector.mClickable, node.isClickable()) &&
                    checkCriteria(selector.mEnabled, node.isEnabled()) &&
                    checkCriteria(selector.mFocused, node.isFocused()) &&
                    checkCriteria(selector.mFocusable, node.isFocusable()) &&
                    checkCriteria(selector.mLongClickable, node.isLongClickable()) &&
                    checkCriteria(selector.mScrollable, node.isScrollable()) &&
                    checkCriteria(selector.mSelected, node.isSelected())) {

                ret = new PartialMatch(selector, absoluteDepth);
            }
            return ret;
        }

        /**
         * Updates this {@link PartialMatch} as part of the tree traversal. Checks to see if
         * {@code node} matches any of the child selectors that need to match for this
         * {@link PartialMatch} to be considered a full match.
         *
         * @param node The node to process.
         * @param index The index of this node underneath its parent.
         * @param depth The distance between {@code node} and the root node.
         * @param rest The list of {@link PartialMatch}es that our caller is currently tracking
         * @return The list of {@link PartialMatch}es that our caller should track while traversing
         * the subtree under this node.
         */
        public SinglyLinkedList<PartialMatch> update(AccessibilityNodeInfo node,
                int index, int depth, SinglyLinkedList<PartialMatch> rest) {

            // Check if this node matches any of our children
            for (BySelector childSelector : matchSelector.mChildSelectors) {
                PartialMatch m = PartialMatch.accept(node, childSelector, index, depth,
                        depth - matchDepth);
                if (m != null) {
                    partialMatches.add(m);
                    rest = SinglyLinkedList.prepend(m, rest);
                }
            }
            return rest;
        }

        /**
         * Finalizes this {@link PartialMatch} and returns true if it was a full match, or false
         * otherwise.
         */
        public boolean finalizeMatch() {
            // Find out which of our child selectors were fully matched
            Set<BySelector> matches = new HashSet<BySelector>();
            for (PartialMatch p : partialMatches) {
                if (p.finalizeMatch()) {
                    matches.add(p.matchSelector);
                }
            }

            // Return true if matches were found for all of the child selectors
            return matches.containsAll(matchSelector.mChildSelectors);
        }
    }

    /**
     * Immutable, singly-linked List. Used to keep track of the {@link PartialMatch}es that we
     * need to update when visiting nodes.
     */
    private static class SinglyLinkedList<T> implements Iterable<T> {

        private final Node<T> mHead;

        /** Constructs an empty list. */
        public SinglyLinkedList() {
            this(null);
        }

        private SinglyLinkedList(Node<T> head) {
            mHead = head;
        }

        /** Returns a new list obtained by prepending {@code data} to {@code rest}. */
        public static <T> SinglyLinkedList<T> prepend(T data, SinglyLinkedList<T> rest) {
            return new SinglyLinkedList<T>(new Node<T>(data, rest.mHead));
        }

        public Iterator<T> iterator() {
            return new Iterator<T>() {
                private Node<T> mNext = mHead;

                @Override
                public boolean hasNext() {
                    return mNext != null;
                }

                @Override
                public T next() {
                    T ret = mNext.data;
                    mNext = mNext.next;
                    return ret;
                }

                @Override
                public void remove() {
                    throw new UnsupportedOperationException();
                }
            };
        }

        private static class Node<T> {
            public final T data;
            public final Node<T> next;

            public Node(T d, Node<T> n) {
                data = d;
                next = n;
            }
        }
    }
}