/*
* Copyright (c) 2014-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.stetho.common.android;
import android.support.annotation.Nullable;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.AdapterView;
import android.widget.HorizontalScrollView;
import android.widget.ScrollView;
import android.widget.Spinner;
import java.util.List;
/**
* This class provides utility methods for determining certain accessibility properties of
* {@link View}s and {@link AccessibilityNodeInfoCompat}s. It is porting some of the checks from
* {@link com.googlecode.eyesfree.utils.AccessibilityNodeInfoUtils}, but has stripped many features
* which are unnecessary here.
*/
public final class AccessibilityUtil {
private AccessibilityUtil() {
}
/**
* Returns whether the specified node has text or a content description.
*
* @param node The node to check.
* @return {@code true} if the node has text.
*/
public static boolean hasText(@Nullable AccessibilityNodeInfoCompat node) {
if (node == null) {
return false;
}
return !TextUtils.isEmpty(node.getText()) || !TextUtils.isEmpty(node.getContentDescription());
}
/**
* Returns whether the supplied {@link View} and {@link AccessibilityNodeInfoCompat} would
* produce spoken feedback if it were accessibility focused. NOTE: not all speaking nodes are
* focusable.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it meets the criterion for producing spoken feedback
*/
public static boolean isSpeakingNode(
@Nullable AccessibilityNodeInfoCompat node,
@Nullable View view) {
if (node == null || view == null) {
return false;
}
if (!node.isVisibleToUser()) {
return false;
}
int important = ViewCompat.getImportantForAccessibility(view);
if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS ||
(important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO &&
node.getChildCount() <= 0)) {
return false;
}
return node.isCheckable() || hasText(node) || hasNonActionableSpeakingDescendants(node, view);
}
/**
* Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any
* children which are not independently accessibility focusable and also have a spoken
* description.
* <p>
* NOTE: Accessibility services will include these children's descriptions in the closest
* focusable ancestor.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it has any non-actionable speaking descendants within its subtree
*/
public static boolean hasNonActionableSpeakingDescendants(
@Nullable AccessibilityNodeInfoCompat node,
@Nullable View view) {
if (node == null || view == null || !(view instanceof ViewGroup)) {
return false;
}
ViewGroup viewGroup = (ViewGroup) view;
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
View childView = viewGroup.getChildAt(i);
if (childView == null) {
continue;
}
AccessibilityNodeInfoCompat childNode = AccessibilityNodeInfoCompat.obtain();
try {
ViewCompat.onInitializeAccessibilityNodeInfo(childView, childNode);
if (isAccessibilityFocusable(childNode, childView)) {
continue;
}
if (isSpeakingNode(childNode, childView)) {
return true;
}
} finally {
childNode.recycle();
}
}
return false;
}
/**
* Determines if the provided {@link View} and {@link AccessibilityNodeInfoCompat} meet the
* criteria for gaining accessibility focus.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it is possible to gain accessibility focus
*/
public static boolean isAccessibilityFocusable(
@Nullable AccessibilityNodeInfoCompat node,
@Nullable View view) {
if (node == null || view == null) {
return false;
}
// Never focus invisible nodes.
if (!node.isVisibleToUser()) {
return false;
}
// Always focus "actionable" nodes.
if (isActionableForAccessibility(node)) {
return true;
}
// only focus top-level list items with non-actionable speaking children.
return isTopLevelScrollItem(node, view) && isSpeakingNode(node, view);
}
/**
* Determines whether the provided {@link View} and {@link AccessibilityNodeInfoCompat} is a
* top-level item in a scrollable container.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it is a top-level item in a scrollable container.
*/
public static boolean isTopLevelScrollItem(
@Nullable AccessibilityNodeInfoCompat node,
@Nullable View view) {
if (node == null || view == null) {
return false;
}
View parent = (View) ViewCompat.getParentForAccessibility(view);
if (parent == null) {
return false;
}
if (node.isScrollable()) {
return true;
}
List actionList = node.getActionList();
if (actionList.contains(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD) ||
actionList.contains(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD)) {
return true;
}
// AdapterView, ScrollView, and HorizontalScrollView are focusable
// containers, but Spinner is a special case.
if (parent instanceof Spinner) {
return false;
}
return
parent instanceof AdapterView ||
parent instanceof ScrollView ||
parent instanceof HorizontalScrollView;
}
/**
* Returns whether a node is actionable. That is, the node supports one of
* {@link AccessibilityNodeInfoCompat#isClickable()},
* {@link AccessibilityNodeInfoCompat#isFocusable()}, or
* {@link AccessibilityNodeInfoCompat#isLongClickable()}.
*
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if node is actionable.
*/
public static boolean isActionableForAccessibility(@Nullable AccessibilityNodeInfoCompat node) {
if (node == null) {
return false;
}
if (node.isClickable() || node.isLongClickable() || node.isFocusable()) {
return true;
}
List actionList = node.getActionList();
return
actionList.contains(AccessibilityNodeInfoCompat.ACTION_CLICK) ||
actionList.contains(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK) ||
actionList.contains(AccessibilityNodeInfoCompat.ACTION_FOCUS);
}
/**
* Determines if any of the provided {@link View}'s and {@link AccessibilityNodeInfoCompat}'s
* ancestors can receive accessibility focus
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if an ancestor of may receive accessibility focus
*/
public static boolean hasFocusableAncestor(
@Nullable AccessibilityNodeInfoCompat node,
@Nullable View view) {
if (node == null || view == null) {
return false;
}
ViewParent parentView = ViewCompat.getParentForAccessibility(view);
if (!(parentView instanceof View)) {
return false;
}
AccessibilityNodeInfoCompat parentNode = AccessibilityNodeInfoCompat.obtain();
try {
ViewCompat.onInitializeAccessibilityNodeInfo((View) parentView, parentNode);
if (parentNode == null) {
return false;
}
if (isAccessibilityFocusable(parentNode, (View) parentView)) {
return true;
}
if (hasFocusableAncestor(parentNode, (View) parentView)) {
return true;
}
} finally {
parentNode.recycle();
}
return false;
}
}