com.android.utils.AccessibilityNodeInfoUtils.java Source code

Java tutorial

Introduction

Here is the source code for com.android.utils.AccessibilityNodeInfoUtils.java

Source

/*
 * Copyright (C) 2012 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 com.android.utils;

import com.google.android.marvin.talkback.TalkBackService;

import android.graphics.Rect;
import android.os.Build;
import android.support.annotation.Nullable;
import android.support.v4.os.BuildCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
import android.support.v4.view.accessibility.AccessibilityWindowInfoCompat;
import android.text.TextUtils;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
import android.webkit.WebView;
import android.widget.AbsListView;
import android.widget.AbsSpinner;
import android.widget.AdapterView;
import android.widget.HorizontalScrollView;
import android.widget.ScrollView;
import android.widget.Spinner;
import com.android.utils.compat.CompatUtils;
import com.android.utils.labeling.CustomLabelManager;
import com.android.utils.labeling.Label;
import com.android.utils.traversal.TraversalStrategy;
import com.android.utils.traversal.TraversalStrategyUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Provides a series of utilities for interacting with AccessibilityNodeInfo
 * objects. NOTE: This class only recycles unused nodes that were collected
 * internally. Any node passed into or returned from a public method is retained
 * and TalkBack should recycle it when appropriate.
 */
public class AccessibilityNodeInfoUtils {
    /**
     * Class for Samsung's TouchWiz implementation of AdapterView. May be
     * {@code null} on non-Samsung devices.
     */
    private static final Class<?> CLASS_TOUCHWIZ_TWADAPTERVIEW = CompatUtils
            .getClass("com.sec.android.touchwiz.widget.TwAdapterView");

    /**
     * Class for Samsung's TouchWiz implementation of AbsListView. May be
     * {@code null} on non-Samsung devices.
     */
    private static final Class<?> CLASS_TOUCHWIZ_TWABSLISTVIEW = CompatUtils
            .getClass("com.sec.android.touchwiz.widget.TwAbsListView");

    private static final String CLASS_RECYCLER_VIEW_CLASS_NAME = "android.support.v7.widget.RecyclerView";

    private static final NodeFilter DEFAULT_FILTER = new NodeFilter() {
        @Override
        public boolean accept(AccessibilityNodeInfoCompat node) {
            return node != null;
        }
    };

    private static final int SYSTEM_ACTION_MAX = 0x01FFFFFF;

    /**
     * Filter for scrollable items. One of the following must be true:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#isScrollable()} returns
     * {@code true}</li>
     * <li>{@link AccessibilityNodeInfoCompat#getActions()} supports
     * {@link AccessibilityNodeInfoCompat#ACTION_SCROLL_FORWARD}</li>
     * <li>{@link AccessibilityNodeInfoCompat#getActions()} supports
     * {@link AccessibilityNodeInfoCompat#ACTION_SCROLL_BACKWARD}</li>
     * </ul>
     */
    public static final NodeFilter FILTER_SCROLLABLE = new NodeFilter() {
        @Override
        public boolean accept(AccessibilityNodeInfoCompat node) {
            return isScrollable(node);
        }
    };

    /**
     * Filter for items that should receive accessibility focus. Equivalent to
     * calling {@link #shouldFocusNode(AccessibilityNodeInfoCompat)}.
     */
    public static final NodeFilter FILTER_SHOULD_FOCUS = new NodeFilter() {
        @Override
        public boolean accept(AccessibilityNodeInfoCompat node) {
            return node != null && shouldFocusNode(node);
        }
    };

    /**
     * Filter that defines which types of views should be auto-scrolled.
     * Generally speaking, only accepts views that are capable of showing
     * partially-visible data.
     * <p>
     * Accepts the following classes (and sub-classes thereof):
     * <ul>
     * <li>{@link android.widget.AbsListView} (and Samsung's TwAbsListView)
     * <li>{@link android.widget.AbsSpinner}
     * <li>{@link android.widget.ScrollView}
     * <li>{@link android.widget.HorizontalScrollView}
     * </ul>
     * <p>
     * Specifically excludes {@link android.widget.AdapterViewAnimator} and
     * sub-classes, since they represent overlapping views. Also excludes
     * {@link android.support.v4.view.ViewPager} since it exclusively represents
     * off-screen views.
     */
    private static final NodeFilter FILTER_AUTO_SCROLL = new NodeFilter() {
        @Override
        public boolean accept(AccessibilityNodeInfoCompat node) {
            if (node.isScrollable()) {
                return nodeMatchesAnyClassByType(node, AbsListView.class, AbsSpinner.class, ScrollView.class,
                        HorizontalScrollView.class, CLASS_TOUCHWIZ_TWABSLISTVIEW)
                        || nodeMatchesClassByName(node, CLASS_RECYCLER_VIEW_CLASS_NAME);
            }

            return false;
        }
    };

    /**
     * This filter accepts scrollable views that break if we place accessibility focus on their
     * child items. Instead, we should just place focus on the entire scrollable view.
     * Note: Only include Android TV views that cannot be updated (i.e. part of a bundled app).
     */
    private static final NodeFilter FILTER_BROKEN_LISTS_TV_M = new NodeFilter() {
        @Override
        public boolean accept(AccessibilityNodeInfoCompat node) {
            if (node == null) {
                return false;
            }

            CharSequence viewId = node.getViewIdResourceName();
            return "com.android.tv.settings:id/setup_scroll_list".equals(viewId)
                    || "com.google.android.gsf.notouch:id/setup_scroll_list".equals(viewId)
                    || "com.android.vending:id/setup_scroll_list".equals(viewId);
        }
    };

    private AccessibilityNodeInfoUtils() {
        // This class is not instantiable.
    }

    /**
     * Gets the text of a <code>node</code> by returning the content description
     * (if available) or by returning the text.
     *
     * @param node The node.
     * @return The node text.
     */
    public static CharSequence getNodeText(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return null;
        }

        // Prefer content description over text.
        // TODO: Why are we checking the trimmed length?
        final CharSequence contentDescription = node.getContentDescription();
        if (!TextUtils.isEmpty(contentDescription) && (TextUtils.getTrimmedLength(contentDescription) > 0)) {
            return contentDescription;
        }

        final CharSequence text = node.getText();
        if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) {
            return text;
        }

        return null;
    }

    public static List<AccessibilityActionCompat> getCustomActions(AccessibilityNodeInfoCompat node) {
        List<AccessibilityActionCompat> customActions = new ArrayList<>();
        for (AccessibilityActionCompat action : node.getActionList()) {
            if (isCustomAction(action)) {
                // We don't use custom actions that doesn't have a label
                if (!TextUtils.isEmpty(action.getLabel())) {
                    customActions.add(action);
                }
            }
        }

        return customActions;
    }

    public static boolean isCustomAction(AccessibilityActionCompat action) {
        return action.getId() > SYSTEM_ACTION_MAX;
    }

    /**
     * Gets the text of a <code>node</code> by returning the content description
     * (if available) or by returning the text. Will use the specified
     * <code>CustomLabelManager</code> as a fall back if both are null.
     *
     * @param node The node.
     * @param labelManager The label manager.
     * @return The node text.
     */
    public static CharSequence getNodeText(AccessibilityNodeInfoCompat node, CustomLabelManager labelManager) {
        CharSequence text = AccessibilityNodeInfoUtils.getNodeText(node);
        if (!TextUtils.isEmpty(text)) {
            return text;
        }

        if (labelManager != null && labelManager.isInitialized()) {
            Label label = labelManager.getLabelForViewIdFromCache(node.getViewIdResourceName());
            if (label != null) {
                return label.getText();
            }
        }
        return null;
    }

    /**
     * Returns the root node of the tree containing {@code node}.
     */
    public static AccessibilityNodeInfoCompat getRoot(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return null;
        }

        Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>();
        AccessibilityNodeInfoCompat current = null;
        AccessibilityNodeInfoCompat parent = AccessibilityNodeInfoCompat.obtain(node);

        try {
            do {
                if (current != null) {
                    if (visitedNodes.contains(current)) {
                        current.recycle();
                        parent.recycle();
                        return null;
                    }
                    visitedNodes.add(current);
                }

                current = parent;
                parent = current.getParent();
            } while (parent != null);
        } finally {
            recycleNodes(visitedNodes);
        }

        return current;
    }

    /**
     * Returns whether a node should receive focus from focus traversal or touch
     * exploration. One of the following must be true:
     * <ul>
     * <li>The node is actionable (see
     * {@link #isActionableForAccessibility(AccessibilityNodeInfoCompat)})</li>
     * <li>The node is a top-level list item (see
     * {@link #isTopLevelScrollItem(AccessibilityNodeInfoCompat)})</li>
     * </ul>
     *
     * @param node The node to check.
     * @return {@code true} of the node is accessibility focusable.
     */
    public static boolean isAccessibilityFocusable(AccessibilityNodeInfoCompat node) {
        Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>();
        try {
            return isAccessibilityFocusableInternal(node, null, visitedNodes);
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(visitedNodes);
        }
    }

    private static boolean isAccessibilityFocusableInternal(AccessibilityNodeInfoCompat node,
            Map<AccessibilityNodeInfoCompat, Boolean> speakingNodeCache,
            Set<AccessibilityNodeInfoCompat> visitedNodes) {
        if (node == null) {
            return false;
        }

        // Never focus invisible nodes.
        if (!isVisible(node)) {
            return false;
        }

        // Always focus "actionable" nodes.
        if (isActionableForAccessibility(node)) {
            return true;
        }

        return isTopLevelScrollItem(node) && isSpeakingNode(node, speakingNodeCache, visitedNodes);
    }

    /**
     * Returns whether a node should receive accessibility focus from
     * navigation. This method should never be called recursively, since it
     * traverses up the parent hierarchy on every call.
     *
     * @see #findFocusFromHover(AccessibilityNodeInfoCompat)
     */
    public static boolean shouldFocusNode(AccessibilityNodeInfoCompat node) {
        return shouldFocusNode(node, null, true);
    }

    public static boolean shouldFocusNode(final AccessibilityNodeInfoCompat node,
            final Map<AccessibilityNodeInfoCompat, Boolean> speakingNodeCache) {
        return shouldFocusNode(node, speakingNodeCache, true);
    }

    public static boolean shouldFocusNode(final AccessibilityNodeInfoCompat node,
            final Map<AccessibilityNodeInfoCompat, Boolean> speakingNodeCache, boolean checkChildren) {
        if (node == null) {
            return false;
        }

        // Inside views that support web navigation, we delegate focus to the view itself and
        // assume that it navigates to and focuses the correct elements.
        if (WebInterfaceUtils.supportsWebActions(node)) {
            return true;
        }

        if (!isVisible(node)) {
            LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Don't focus, node is not visible");
            return false;
        }

        // Only allow node with same bounds as window if it is clickable or leaf.
        if (areBoundsIdenticalToWindow(node) && !isClickable(node) && node.getChildCount() > 0) {
            LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                    "Don't focus, node bounds are same as window root node bounds");
            return false;
        }

        HashSet<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>();
        try {
            boolean accessibilityFocusable = isAccessibilityFocusableInternal(node, speakingNodeCache,
                    visitedNodes);

            if (!checkChildren) {
                // End of the line. Don't check children and don't allow any recursion.
                return accessibilityFocusable;
            }

            if (accessibilityFocusable) {
                AccessibilityNodeInfoUtils.recycleNodes(visitedNodes);
                visitedNodes.clear();
                // TODO: This may still result in focusing non-speaking nodes, but it
                // won't prevent unlabeled buttons from receiving focus.
                if (!hasVisibleChildren(node)) {
                    LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                            "Focus, node is focusable and has no visible children");
                    return true;
                } else if (isSpeakingNode(node, speakingNodeCache, visitedNodes)) {
                    LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                            "Focus, node is focusable and has something to speak");
                    return true;
                } else {
                    LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                            "Don't focus, node is focusable but has nothing to speak");
                    return false;
                }
            }
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(visitedNodes);
        }

        // If this node has no focusable ancestors, but it still has text,
        // then it should receive focus from navigation and be read aloud.
        NodeFilter filter = new NodeFilter() {
            @Override
            public boolean accept(AccessibilityNodeInfoCompat node) {
                return shouldFocusNode(node, speakingNodeCache, false);
            }
        };

        if (!hasMatchingAncestor(node, filter) && hasText(node)) {
            LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                    "Focus, node has text and no focusable ancestors");
            return true;
        }

        LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Don't focus, failed all focusability tests");
        return false;
    }

    /**
     * Returns the node that should receive focus from hover by starting from
     * the touched node and calling {@link #shouldFocusNode} at each level of
     * the view hierarchy.
     */
    public static AccessibilityNodeInfoCompat findFocusFromHover(AccessibilityNodeInfoCompat touched) {
        return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(touched, FILTER_SHOULD_FOCUS);
    }

    private static boolean isSpeakingNode(AccessibilityNodeInfoCompat node,
            Map<AccessibilityNodeInfoCompat, Boolean> speakingNodeCache,
            Set<AccessibilityNodeInfoCompat> visitedNodes) {
        if (speakingNodeCache != null && speakingNodeCache.containsKey(node)) {
            return speakingNodeCache.get(node);
        }

        boolean result = false;
        if (hasText(node)) {
            LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Speaking, has text");
            result = true;
        } else if (node.isCheckable()) { // Special case for check boxes.
            LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Speaking, is checkable");
            result = true;
        } else if (WebInterfaceUtils.hasLegacyWebContent(node)) { // Special case for web content.
            LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Speaking, has web content");
            result = true;
        } else if (hasNonActionableSpeakingChildren(node, speakingNodeCache, visitedNodes)) {
            // Special case for containers with non-focusable content.
            LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                    "Speaking, has non-actionable speaking children");
            result = true;
        }

        if (speakingNodeCache != null) {
            speakingNodeCache.put(node, result);
        }

        return result;
    }

    private static boolean hasNonActionableSpeakingChildren(AccessibilityNodeInfoCompat node,
            Map<AccessibilityNodeInfoCompat, Boolean> speakingNodeCache,
            Set<AccessibilityNodeInfoCompat> visitedNodes) {
        final int childCount = node.getChildCount();

        AccessibilityNodeInfoCompat child;

        // Has non-actionable, speaking children?
        for (int i = 0; i < childCount; i++) {
            child = node.getChild(i);

            if (child == null) {
                LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Child %d is null, skipping it", i);
                continue;
            }

            if (!visitedNodes.add(child)) {
                child.recycle();
                return false;
            }

            // Ignore invisible nodes.
            if (!isVisible(child)) {
                LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Child %d is invisible, skipping it",
                        i);
                continue;
            }

            // Ignore focusable nodes.
            if (isAccessibilityFocusableInternal(child, speakingNodeCache, visitedNodes)) {
                LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE, "Child %d is focusable, skipping it",
                        i);
                continue;
            }

            // Recursively check non-focusable child nodes.
            if (isSpeakingNode(child, speakingNodeCache, visitedNodes)) {
                LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                        "Does have actionable speaking children (child %d)", i);
                return true;
            }
        }

        LogUtils.log(AccessibilityNodeInfoUtils.class, Log.VERBOSE,
                "Does not have non-actionable speaking children");
        return false;
    }

    private static boolean hasVisibleChildren(AccessibilityNodeInfoCompat node) {
        int childCount = node.getChildCount();
        for (int i = 0; i < childCount; ++i) {
            AccessibilityNodeInfoCompat child = node.getChild(i);
            if (child != null) {
                try {
                    if (child.isVisibleToUser()) {
                        return true;
                    }
                } finally {
                    child.recycle();
                }
            }
        }

        return false;
    }

    /**
     * Returns whether a node is actionable. That is, the node supports one of
     * the following actions:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#isClickable()}
     * <li>{@link AccessibilityNodeInfoCompat#isFocusable()}
     * <li>{@link AccessibilityNodeInfoCompat#isLongClickable()}
     * </ul>
     * This parities the system method View#isActionableForAccessibility(), which
     * was added in JellyBean.
     *
     * @param node The node to examine.
     * @return {@code true} if node is actionable.
     */
    public static boolean isActionableForAccessibility(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return false;
        }

        // Nodes that are clickable are always actionable.
        if (isClickable(node) || isLongClickable(node)) {
            return true;
        }

        if (node.isFocusable()) {
            return true;
        }

        if (WebInterfaceUtils.hasNativeWebContent(node)) {
            return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS);
        }

        return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS,
                AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT,
                AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT);
    }

    public static boolean isSelfOrAncestorFocused(AccessibilityNodeInfoCompat node) {
        return node != null && (node.isAccessibilityFocused() || hasMatchingAncestor(node, new NodeFilter() {
            @Override
            public boolean accept(AccessibilityNodeInfoCompat node) {
                return node.isAccessibilityFocused();
            }
        }));

    }

    /**
     * Returns whether a node is clickable. That is, the node supports at least one of the
     * following:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#isClickable()}</li>
     * <li>{@link AccessibilityNodeInfoCompat#ACTION_CLICK}</li>
     * </ul>
     *
     * @param node The node to examine.
     * @return {@code true} if node is clickable.
     */
    public static boolean isClickable(AccessibilityNodeInfoCompat node) {
        return node != null
                && (node.isClickable() || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_CLICK));
    }

    /**
     * Returns whether a node is long clickable. That is, the node supports at least one of the
     * following:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#isLongClickable()}</li>
     * <li>{@link AccessibilityNodeInfoCompat#ACTION_LONG_CLICK}</li>
     * </ul>
     *
     * @param node The node to examine.
     * @return {@code true} if node is long clickable.
     */
    public static boolean isLongClickable(AccessibilityNodeInfoCompat node) {
        return node != null && (node.isLongClickable()
                || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_LONG_CLICK));

    }

    /**
     * Returns whether a node is expandable. That is, the node supports the following action:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#ACTION_EXPAND}</li>
     * </ul>
     *
     * @param node The node to examine.
     * @return {@code true} if node is expandable.
     */
    public static boolean isExpandable(AccessibilityNodeInfoCompat node) {
        return node != null && supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_EXPAND);
    }

    /**
     * Returns whether a node is collapsible. That is, the node supports the following action:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#ACTION_COLLAPSE}</li>
     * </ul>
     *
     * @param node The node to examine.
     * @return {@code true} if node is collapsible.
     */
    public static boolean isCollapsible(AccessibilityNodeInfoCompat node) {
        return node != null && supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_COLLAPSE);
    }

    /**
     * Returns whether a node is editable.
     *
     * @param node The node to examine.
     * @return {@code true} if node is editable.
     */
    public static boolean isEditable(AccessibilityNodeInfoCompat node) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
            return ((AccessibilityNodeInfo) node.getInfo()).isEditable();
        } else {
            return Role.getRole(node) == Role.ROLE_EDIT_TEXT
                    || nodeMatchesClassByName(node, "com.google.android.search.searchplate.SimpleSearchText");
        }
    }

    /**
     * Check whether a given node has a scrollable ancestor.
     *
     * @param node The node to examine.
     * @return {@code true} if one of the node's ancestors is scrollable.
     */
    public static boolean hasMatchingAncestor(AccessibilityNodeInfoCompat node, NodeFilter filter) {
        if (node == null) {
            return false;
        }

        final AccessibilityNodeInfoCompat result = getMatchingAncestor(node, filter);
        if (result == null) {
            return false;
        }

        result.recycle();
        return true;
    }

    /**
     * Check whether a given node or any of its ancestors matches the given filter.
     *
     * @param node The node to examine.
     * @param filter The filter to match the nodes against.
     * @return {@code true} if the node or one of its ancestors matches the filter.
     */
    public static boolean isOrHasMatchingAncestor(AccessibilityNodeInfoCompat node, NodeFilter filter) {
        if (node == null) {
            return false;
        }

        final AccessibilityNodeInfoCompat result = getSelfOrMatchingAncestor(node, filter);
        if (result == null) {
            return false;
        }

        result.recycle();
        return true;
    }

    /**
     * Check whether a given node has any descendant matching a given filter.
     */
    public static boolean hasMatchingDescendant(AccessibilityNodeInfoCompat node, NodeFilter filter) {
        if (node == null) {
            return false;
        }

        final AccessibilityNodeInfoCompat result = getMatchingDescendant(node, filter);
        if (result == null) {
            return false;
        }

        result.recycle();
        return true;
    }

    /**
     * Returns the {@code node} if it matches the {@code filter}, or the first
     * matching ancestor. Returns {@code null} if no nodes match.
     */
    public static AccessibilityNodeInfoCompat getSelfOrMatchingAncestor(AccessibilityNodeInfoCompat node,
            NodeFilter filter) {
        if (node == null) {
            return null;
        }

        if (filter.accept(node)) {
            return AccessibilityNodeInfoCompat.obtain(node);
        }

        return getMatchingAncestor(node, filter);
    }

    /**
     * Returns the {@code node} if it matches the {@code filter}, or the first
     * matching ancestor, ending the ancestor search once it reaches {@code end}.
     * The search is inclusive of {@code node} but exclusive of {@code end}.
     * If {@code node} equals {@code end}, then {@code node} is an eligible match.
     * Returns {@code null} if no nodes match.
     */
    public static AccessibilityNodeInfoCompat getSelfOrMatchingAncestor(AccessibilityNodeInfoCompat node,
            AccessibilityNodeInfoCompat end, NodeFilter filter) {
        if (node == null) {
            return null;
        }

        if (filter.accept(node)) {
            return AccessibilityNodeInfoCompat.obtain(node);
        }

        return getMatchingAncestor(node, end, filter);
    }

    /**
     * Returns the {@code node} if it matches the {@code filter}, or the first
     * matching descendant. Returns {@code null} if no nodes match.
     */
    public static AccessibilityNodeInfoCompat getSelfOrMatchingDescendant(AccessibilityNodeInfoCompat node,
            NodeFilter filter) {
        if (node == null) {
            return null;
        }

        if (filter.accept(node)) {
            return AccessibilityNodeInfoCompat.obtain(node);
        }

        return getMatchingDescendant(node, filter);
    }

    /**
     * Determines whether the two nodes are in the same branch; that is, they are equal or one
     * is the ancestor of the other.
     */
    public static boolean areInSameBranch(@Nullable final AccessibilityNodeInfoCompat node1,
            @Nullable final AccessibilityNodeInfoCompat node2) {
        if (node1 != null && node2 != null) {
            // Same node?
            if (node1.equals(node2)) {
                return true;
            }

            // Is node1 an ancestor of node2?
            NodeFilter matchNode1 = new NodeFilter() {
                @Override
                public boolean accept(AccessibilityNodeInfoCompat node) {
                    return node != null && node.equals(node1);
                }
            };
            if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node2, matchNode1)) {
                return true;
            }

            // Is node2 an ancestor of node1?
            NodeFilter matchNode2 = new NodeFilter() {
                @Override
                public boolean accept(AccessibilityNodeInfoCompat node) {
                    return node != null && node.equals(node2);
                }
            };
            if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node1, matchNode2)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Returns the first ancestor of {@code node} that matches the
     * {@code filter}. Returns {@code null} if no nodes match.
     */
    private static AccessibilityNodeInfoCompat getMatchingAncestor(AccessibilityNodeInfoCompat node,
            NodeFilter filter) {
        return getMatchingAncestor(node, null, filter);
    }

    /**
     * Returns the first ancestor of {@code node} that matches the {@code filter}, terminating the
     * search once it reaches {@code end}. The search is exclusive of both {@code node} and
     * {@code end}. Returns {@code null} if no nodes match.
     */
    private static AccessibilityNodeInfoCompat getMatchingAncestor(AccessibilityNodeInfoCompat node,
            AccessibilityNodeInfoCompat end, NodeFilter filter) {
        if (node == null) {
            return null;
        }

        final HashSet<AccessibilityNodeInfoCompat> ancestors = new HashSet<>();

        try {
            ancestors.add(AccessibilityNodeInfoCompat.obtain(node));
            node = node.getParent();

            while (node != null) {
                if (!ancestors.add(node)) {
                    // Already seen this node, so abort!
                    node.recycle();
                    return null;
                }

                if (end != null && node.equals(end)) {
                    // Reached the end node, so abort!
                    // Don't recycle the node here, it was added to ancestors and will be recycled.
                    return null;
                }

                if (filter.accept(node)) {
                    // Send a copy since node gets recycled.
                    return AccessibilityNodeInfoCompat.obtain(node);
                }

                node = node.getParent();
            }
        } finally {
            recycleNodes(ancestors);
        }

        return null;
    }

    /**
     * Returns the number of ancestors matching the given filter. Does not include the current
     * node in the count, even if it matches the filter. If there is a cycle in the ancestor
     * hierarchy, then this method will return 0.
     */
    public static int countMatchingAncestors(AccessibilityNodeInfoCompat node, NodeFilter filter) {
        if (node == null) {
            return 0;
        }

        final HashSet<AccessibilityNodeInfoCompat> ancestors = new HashSet<>();
        int matchingAncestors = 0;

        try {
            ancestors.add(AccessibilityNodeInfoCompat.obtain(node));
            node = node.getParent();

            while (node != null) {
                if (!ancestors.add(node)) {
                    // Already seen this node, so abort!
                    node.recycle();
                    return 0;
                }

                if (filter.accept(node)) {
                    matchingAncestors++;
                }

                node = node.getParent();
            }
        } finally {
            recycleNodes(ancestors);
        }

        return matchingAncestors;
    }

    /**
     * Returns the first child (by depth-first search) of {@code node} that matches the
     * {@code filter}. Returns {@code null} if no nodes match.
     * The caller is responsible for recycling all nodes in {@code visitedNodes} and the node
     * returned by this method, if non-{@code null}.
     */
    private static AccessibilityNodeInfoCompat getMatchingDescendant(AccessibilityNodeInfoCompat node,
            NodeFilter filter, HashSet<AccessibilityNodeInfoCompat> visitedNodes) {
        if (node == null) {
            return null;
        }

        if (visitedNodes.contains(node)) {
            return null;
        } else {
            visitedNodes.add(AccessibilityNodeInfoCompat.obtain(node));
        }

        int childCount = node.getChildCount();
        for (int i = 0; i < childCount; ++i) {
            AccessibilityNodeInfoCompat child = node.getChild(i);

            if (child == null) {
                continue;
            }

            if (filter.accept(child)) {
                return child; // child was already obtained by node.getChild().
            }

            try {
                AccessibilityNodeInfoCompat childMatch = getMatchingDescendant(child, filter, visitedNodes);
                if (childMatch != null) {
                    return childMatch;
                }
            } finally {
                child.recycle();
            }
        }

        return null;
    }

    private static AccessibilityNodeInfoCompat getMatchingDescendant(AccessibilityNodeInfoCompat node,
            NodeFilter filter) {
        final HashSet<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>();
        try {
            return getMatchingDescendant(node, filter, visitedNodes);
        } finally {
            recycleNodes(visitedNodes);
        }
    }

    /**
     * Check whether a given node is scrollable.
     *
     * @param node The node to examine.
     * @return {@code true} if the node is scrollable.
     */
    private static boolean isScrollable(AccessibilityNodeInfoCompat node) {
        return node.isScrollable() || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD,
                AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);

    }

    /**
     * Returns whether the specified node has text.
     * For the purposes of this check, any node with a CollectionInfo is considered to not have
     * text since its text and content description are used only for collection transitions.
     *
     * @param node The node to check.
     * @return {@code true} if the node has text.
     */
    private static boolean hasText(AccessibilityNodeInfoCompat node) {
        return node != null && node.getCollectionInfo() == null
                && (!TextUtils.isEmpty(node.getText()) || !TextUtils.isEmpty(node.getContentDescription()));

    }

    /**
     * Determines whether a node is a top-level item in a scrollable container.
     *
     * @param node The node to test.
     * @return {@code true} if {@code node} is a top-level item in a scrollable
     *         container.
     */
    public static boolean isTopLevelScrollItem(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return false;
        }

        AccessibilityNodeInfoCompat parent = null;
        AccessibilityNodeInfoCompat grandparent = null;

        try {
            parent = node.getParent();
            if (parent == null) {
                // Not a child node of anything.
                return false;
            }

            // Certain scrollable views in M's Android TV SetupWraith are permanently broken and
            // won't ever be fixed because the setup wizard is bundled. This affects <= M only.
            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M && FILTER_BROKEN_LISTS_TV_M.accept(parent)) {
                return false;
            }

            if (isScrollable(node)) {
                return true;
            }

            // AdapterView, ScrollView, and HorizontalScrollView are focusable
            // containers, but Spinner is a special case.
            // TODO: Rename or break up this method, since it actually returns
            // whether the parent is scrollable OR is a focusable container that
            // should not block its children from receiving focus.
            //noinspection SimplifiableIfStatement
            if (Role.getRole(parent) == Role.ROLE_DROP_DOWN_LIST)
                return false;

            // Top-level items in a scrolling pager are actually two levels down since the first
            // level items in pagers are the pages themselves.
            grandparent = parent.getParent();
            if (Role.getRole(grandparent) == Role.ROLE_PAGER)
                return true;

            return nodeMatchesAnyClassByType(parent, AdapterView.class, ScrollView.class,
                    HorizontalScrollView.class, CLASS_TOUCHWIZ_TWADAPTERVIEW)
                    || nodeMatchesClassByName(parent, CLASS_RECYCLER_VIEW_CLASS_NAME);
        } finally {
            recycleNodes(parent, grandparent);
        }
    }

    /**
     * Determines if the current item is at the logical edge of a list by checking the
     * scrollable predecessors of the items going forwards and backwards.
     *
     * @param node The node to check.
     * @param traversalStrategy - traversal strategy that is used to define order of node
     * @return true if the current item is at the edge of a list.
     */
    public static boolean isEdgeListItem(AccessibilityNodeInfoCompat node, TraversalStrategy traversalStrategy) {
        return isEdgeListItem(node, TraversalStrategy.SEARCH_FOCUS_BACKWARD, null, traversalStrategy)
                || isEdgeListItem(node, TraversalStrategy.SEARCH_FOCUS_FORWARD, null, traversalStrategy);
    }

    /**
     * Determines if the current item is at the edge of a list by checking the
     * scrollable predecessors of the items in a relative or absolute direction.
     *
     * @param node The node to check.
     * @param direction The direction in which to check.
     * @param filter (Optional) Filter used to validate list-type ancestors.
     * @param traversalStrategy - traversal strategy that is used to define order of node
     * @return true if the current item is at the edge of a list.
     */
    private static boolean isEdgeListItem(AccessibilityNodeInfoCompat node,
            @TraversalStrategy.SearchDirection int direction, NodeFilter filter,
            TraversalStrategy traversalStrategy) {
        if (node == null) {
            return false;
        }

        int scrollAction = TraversalStrategyUtils.convertSearchDirectionToScrollAction(direction);
        if (scrollAction != 0) {
            NodeActionFilter scrollableFilter = new NodeActionFilter(scrollAction);
            NodeFilter comboFilter = scrollableFilter.and(filter);
            return isMatchingEdgeListItem(node, direction, comboFilter, traversalStrategy);
        }

        return false;
    }

    /**
     * Convenience method determining if the current item is at the edge of a
     * list and suitable autoscroll. Calls {@code isEdgeListItem} with
     * {@code FILTER_AUTO_SCROLL}.
     *
     * @param node The node to check.
     * @param direction The direction in which to check, one of:
     *            <ul>
     *            <li>{@code -1} to check backward
     *            <li>{@code 0} to check both backward and forward
     *            <li>{@code 1} to check forward
     *            </ul>
     * @param traversalStrategy - traversal strategy that is used to define order of node
     * @return true if the current item is at the edge of a list.
     */
    public static boolean isAutoScrollEdgeListItem(AccessibilityNodeInfoCompat node, int direction,
            TraversalStrategy traversalStrategy) {
        return isEdgeListItem(node, direction, FILTER_AUTO_SCROLL, traversalStrategy);
    }

    /**
     * Utility method for determining if a searching past a particular node will
     * fall off the edge of a scrollable container.
     *
     * @param cursor Node to check.
     * @param direction The direction in which to move from the cursor.
     * @param filter Filter used to validate list-type ancestors.
     * @param traversalStrategy - traversal strategy that is used to define order of node
     * @return {@code true} if focusing search in the specified direction will
     *         fall off the edge of the container.
     */
    private static boolean isMatchingEdgeListItem(AccessibilityNodeInfoCompat cursor,
            @TraversalStrategy.SearchDirection int direction, NodeFilter filter,
            TraversalStrategy traversalStrategy) {
        AccessibilityNodeInfoCompat ancestor = null;
        AccessibilityNodeInfoCompat nextFocusNode = null;
        AccessibilityNodeInfoCompat searchedAncestor = null;

        try {
            ancestor = getMatchingAncestor(cursor, filter);
            if (ancestor == null) {
                // Not contained in a scrollable list.
                return false;
            }

            nextFocusNode = searchFocus(traversalStrategy, cursor, direction, FILTER_SHOULD_FOCUS);
            if ((nextFocusNode == null) || nextFocusNode.equals(ancestor)) {
                // Can't move from this position.
                return true;
            }

            // if nextFocusNode is in WebView and not visible to user we still could set
            // accessibility  focus on it and WebView scrolls itself to show newly focused item
            // on the screen. But there could be situation that node is inside WebView bounds but
            // WebView is [partially] outside the screen bounds. In that case we don't ask WebView
            // to set accessibility focus but try to scroll scrollable parent to get the WebView
            // with nextFocusNode inside it to the screen bounds.
            if (!nextFocusNode.isVisibleToUser() && WebInterfaceUtils.hasNativeWebContent(nextFocusNode)) {
                AccessibilityNodeInfoCompat webViewNode = getMatchingAncestor(nextFocusNode, new NodeFilter() {
                    @Override
                    public boolean accept(AccessibilityNodeInfoCompat node) {
                        return Role.getRole(node) == Role.ROLE_WEB_VIEW;
                    }
                });

                if (webViewNode != null
                        && (!webViewNode.isVisibleToUser() || isNodeInBoundsOfOther(webViewNode, nextFocusNode))) {
                    return true;
                }
            }

            searchedAncestor = getMatchingAncestor(nextFocusNode, filter);
            while (searchedAncestor != null) {
                if (ancestor.equals(searchedAncestor)) {
                    return false;
                }
                AccessibilityNodeInfoCompat temp = searchedAncestor;
                searchedAncestor = getMatchingAncestor(searchedAncestor, filter);
                temp.recycle();
            }
            // Moves outside of the scrollable container.
            return true;
        } finally {
            recycleNodes(ancestor, nextFocusNode, searchedAncestor);
        }
    }

    private static boolean isNodeInBoundsOfOther(AccessibilityNodeInfoCompat outerNode,
            AccessibilityNodeInfoCompat innerNode) {
        if (outerNode == null || innerNode == null) {
            return false;
        }

        Rect outerRect = new Rect();
        Rect innerRect = new Rect();
        outerNode.getBoundsInScreen(outerRect);
        innerNode.getBoundsInScreen(innerRect);

        if (outerRect.top > innerRect.bottom || outerRect.bottom < innerRect.top) {
            return false;
        }

        //noinspection RedundantIfStatement
        if (outerRect.left > innerRect.right || outerRect.right < innerRect.left) {
            return false;
        }

        return true;
    }

    public static boolean hasAncestor(AccessibilityNodeInfoCompat node,
            final AccessibilityNodeInfoCompat targetAncestor) {
        if (node == null || targetAncestor == null) {
            return false;
        }

        NodeFilter filter = new NodeFilter() {
            @Override
            public boolean accept(AccessibilityNodeInfoCompat node) {
                return targetAncestor.equals(node);
            }
        };

        AccessibilityNodeInfoCompat foundAncestor = getMatchingAncestor(node, filter);
        if (foundAncestor != null) {
            foundAncestor.recycle();
            return true;
        }

        return false;
    }

    /**
     * Determines if the generating class of an
     * {@link AccessibilityNodeInfoCompat} matches any of the given
     * {@link Class}es by type.
     *
     * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by
     *            the accessibility framework.
     * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object
     *         matches the {@link Class} by type or inherited type,
     *         {@code false} otherwise.
     * @param referenceClasses A variable-length list of {@link Class} objects
     *            to match by type or inherited type.
     */
    private static boolean nodeMatchesAnyClassByType(AccessibilityNodeInfoCompat node,
            Class<?>... referenceClasses) {
        if (node == null)
            return false;

        for (Class<?> referenceClass : referenceClasses) {
            if (ClassLoadingCache.checkInstanceOf(node.getClassName(), referenceClass)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Determines if the class of an {@link AccessibilityNodeInfoCompat} matches
     * a given {@link Class} by package and name.
     *
     * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by
     *            the accessibility framework.
     * @param referenceClassName A class name to match.
     * @return {@code true} if the {@link AccessibilityNodeInfoCompat} matches
     *         the class name.
     */
    public static boolean nodeMatchesClassByName(AccessibilityNodeInfoCompat node,
            CharSequence referenceClassName) {
        return node != null && ClassLoadingCache.checkInstanceOf(node.getClassName(), referenceClassName);

    }

    /**
     * Recycles the given nodes.
     *
     * @param nodes The nodes to recycle.
     */
    public static void recycleNodes(Collection<AccessibilityNodeInfoCompat> nodes) {
        if (nodes == null) {
            return;
        }

        for (AccessibilityNodeInfoCompat node : nodes) {
            if (node != null) {
                node.recycle();
            }
        }

        nodes.clear();
    }

    /**
     * Recycles the given nodes.
     *
     * @param nodes The nodes to recycle.
     */
    public static void recycleNodes(AccessibilityNodeInfoCompat... nodes) {
        if (nodes == null) {
            return;
        }

        for (AccessibilityNodeInfoCompat node : nodes) {
            if (node != null) {
                node.recycle();
            }
        }
    }

    /**
     * Returns {@code true} if the node supports at least one of the specified
     * actions. To check whether a node supports multiple actions, combine them
     * using the {@code |} (logical OR) operator.
     *
     * Note: this method will check against the getActions() method of AccessibilityNodeInfo, which
     * will not contain information for actions introduced in API level 21 or later.
     *
     * @param node The node to check.
     * @param actions The actions to check.
     * @return {@code true} if at least one action is supported.
     */
    public static boolean supportsAnyAction(AccessibilityNodeInfoCompat node, int... actions) {
        if (node != null) {
            final int supportedActions = node.getActions();

            for (int action : actions) {
                if ((supportedActions & action) == action) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Returns {@code true} if the node supports the specified action. This method supports actions
     * introduced in API level 21 and later. However, it does not support bitmasks.
     *
     */
    public static boolean supportsAction(AccessibilityNodeInfoCompat node, int action) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            // New actions in >= API 21 won't appear in getActions() but in getActionList().
            // On Lollipop+ devices, pre-API 21 actions will also appear in getActionList().
            List<AccessibilityActionCompat> actions = node.getActionList();
            int size = actions.size();
            for (int i = 0; i < size; ++i) {
                AccessibilityActionCompat actionCompat = actions.get(i);
                if (actionCompat.getId() == action) {
                    return true;
                }
            }
            return false;
        } else {
            // On < API 21, actions aren't guaranteed to appear in getActionsList(), so we need to
            // check getActions() instead.
            return (node.getActions() & action) == action;
        }
    }

    /**
     * Returns the result of applying a filter using breadth-first traversal.
     *
     * @param node The root node to traverse from.
     * @param filter The filter to satisfy.
     * @return The first node reached via BFS traversal that satisfies the
     *         filter.
     */
    public static AccessibilityNodeInfoCompat searchFromBfs(AccessibilityNodeInfoCompat node, NodeFilter filter) {
        if (node == null) {
            return null;
        }

        final LinkedList<AccessibilityNodeInfoCompat> queue = new LinkedList<>();
        Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>();

        queue.add(AccessibilityNodeInfoCompat.obtain(node));

        try {
            while (!queue.isEmpty()) {
                final AccessibilityNodeInfoCompat item = queue.removeFirst();
                visitedNodes.add(item);

                if (filter.accept(item)) {
                    return item;
                }

                final int childCount = item.getChildCount();

                for (int i = 0; i < childCount; i++) {
                    final AccessibilityNodeInfoCompat child = item.getChild(i);

                    if (child != null && !visitedNodes.contains(child)) {
                        queue.addLast(child);
                    }
                }
                item.recycle();
            }
        } finally {
            while (!queue.isEmpty()) {
                queue.removeFirst().recycle();
            }
        }

        return null;
    }

    /**
     * Search focus that satisfied specified node filter from currentFocus to specified direction
     * according to OrderTraversal strategy
     * @param traversal - order traversal strategy
     * @param currentFocus - node that is starting point of focus search
     * @param direction - direction the target focus is searching to
     * @param filter - filters focused node candidate
     * @return node that could be focused next
     */
    public static AccessibilityNodeInfoCompat searchFocus(TraversalStrategy traversal,
            AccessibilityNodeInfoCompat currentFocus, @TraversalStrategy.SearchDirection int direction,
            NodeFilter filter) {
        if (traversal == null || currentFocus == null) {
            return null;
        }

        if (filter == null) {
            filter = DEFAULT_FILTER;
        }

        AccessibilityNodeInfoCompat targetNode = AccessibilityNodeInfoCompat.obtain(currentFocus);
        Set<AccessibilityNodeInfoCompat> seenNodes = new HashSet<>();
        try {
            do {
                seenNodes.add(targetNode);
                targetNode = traversal.findFocus(targetNode, direction);

                if (seenNodes.contains(targetNode)) {
                    LogUtils.log(AccessibilityNodeInfoUtils.class, Log.ERROR,
                            "Found duplicate during traversal: %s", targetNode.getInfo());
                    return null;
                }
            } while (targetNode != null && !filter.accept(targetNode));
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(seenNodes);
        }

        return targetNode;
    }

    /**
     * Returns a fresh copy of {@code node} with properties that are
     * less likely to be stale.  Returns {@code null} if the node can't be
     * found anymore.
     */
    public static AccessibilityNodeInfoCompat refreshNode(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return null;
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
            AccessibilityNodeInfoCompat nodeCopy = AccessibilityNodeInfoCompat.obtain(node);
            nodeCopy.refresh();
            return nodeCopy;
        }

        AccessibilityNodeInfoCompat result = refreshFromChild(node);
        if (result == null) {
            result = refreshFromParent(node);
        }

        return result;
    }

    private static AccessibilityNodeInfoCompat refreshFromChild(AccessibilityNodeInfoCompat node) {
        if (node.getChildCount() > 0) {
            AccessibilityNodeInfoCompat firstChild = node.getChild(0);
            if (firstChild != null) {
                AccessibilityNodeInfoCompat parent = firstChild.getParent();
                firstChild.recycle();
                if (node.equals(parent)) {
                    return parent;
                } else {
                    recycleNodes(parent);
                }
            }
        }
        return null;
    }

    private static AccessibilityNodeInfoCompat refreshFromParent(AccessibilityNodeInfoCompat node) {
        AccessibilityNodeInfoCompat parent = node.getParent();
        if (parent != null) {
            try {
                int childCount = parent.getChildCount();
                for (int i = 0; i < childCount; ++i) {
                    AccessibilityNodeInfoCompat child = parent.getChild(i);
                    if (node.equals(child)) {
                        return child;
                    }
                    recycleNodes(child);
                }
            } finally {
                parent.recycle();
            }
        }
        return null;
    }

    /**
     * Returns a fresh copy of node by traversing the given window for a similar node.
     * For example, the node that you want might be in a popup window that has closed and re-opened,
     * causing the accessibility IDs of its views to be different.
     * Note: you must recycle the node that is returned from this method.
     */
    public static AccessibilityNodeInfoCompat refreshNodeFuzzy(final AccessibilityNodeInfoCompat node,
            AccessibilityWindowInfo window) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            return null;
        }

        if (window == null || node == null) {
            return null;
        }

        AccessibilityNodeInfo root = window.getRoot();
        if (root == null) {
            return null;
        }

        NodeFilter similarFilter = new NodeFilter() {
            @Override
            public boolean accept(AccessibilityNodeInfoCompat other) {
                return other != null && TextUtils.equals(node.getText(), other.getText());
            }
        };

        AccessibilityNodeInfoCompat rootCompat = new AccessibilityNodeInfoCompat(root);
        try {
            return getMatchingDescendant(rootCompat, similarFilter);
        } finally {
            rootCompat.recycle();
        }
    }

    /**
     * Helper method that returns {@code true} if the specified node is visible
     * to the user
     */
    public static boolean isVisible(AccessibilityNodeInfoCompat node) {
        return node != null && (node.isVisibleToUser() || WebInterfaceUtils.isWebContainer(node));
    }

    /**
     * Determines whether the specified node has bounds identical to the bounds of its window.
     */
    private static boolean areBoundsIdenticalToWindow(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return false;
        }

        AccessibilityWindowInfoCompat window = node.getWindow();
        if (window == null) {
            return false;
        }

        Rect windowBounds = new Rect();
        window.getBoundsInScreen(windowBounds);

        Rect nodeBounds = new Rect();
        node.getBoundsInScreen(nodeBounds);

        return windowBounds.equals(nodeBounds);
    }

    /**
     * Returns the node to which the given node's window is anchored, if there is an anchor.
     * Note: you must recycle the node that is returned from this method.
     */
    public static AccessibilityNodeInfoCompat getAnchor(@Nullable AccessibilityNodeInfoCompat node) {
        if (!BuildCompat.isAtLeastN()) {
            return null;
        }

        if (node == null) {
            return null;
        }

        AccessibilityNodeInfo nativeNode = (AccessibilityNodeInfo) node.getInfo();
        if (nativeNode == null) {
            return null;
        }

        AccessibilityWindowInfo nativeWindow = nativeNode.getWindow();
        if (nativeWindow == null) {
            return null;
        }

        AccessibilityNodeInfo nativeAnchor = nativeWindow.getAnchor();
        if (nativeAnchor == null) {
            return null;
        }

        return new AccessibilityNodeInfoCompat(nativeAnchor);
    }

    /**
     * Convenience class for a {@link NodeFilter} that checks whether nodes
     * support a specific action.
     */
    private static class NodeActionFilter extends NodeFilter {
        private final int mAction;

        /**
         * Creates a new action filter with the specified action mask.
         *
         * @param action The ID of the action to accept.
         */
        public NodeActionFilter(int action) {
            mAction = action;
        }

        @Override
        public boolean accept(AccessibilityNodeInfoCompat node) {
            return supportsAction(node, mAction);
        }
    }
}