/*
* 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);
}
}
}