/* * 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.inspector.elements.android; import android.support.annotation.Nullable; import android.support.v4.view.ViewCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.widget.EditText; import com.facebook.stetho.common.android.AccessibilityUtil; public final class AccessibilityNodeInfoWrapper { public AccessibilityNodeInfoWrapper() { } public static AccessibilityNodeInfoCompat createNodeInfoFromView(View view) { AccessibilityNodeInfoCompat nodeInfo = AccessibilityNodeInfoCompat.obtain(); ViewCompat.onInitializeAccessibilityNodeInfo(view, nodeInfo); return nodeInfo; } public static boolean getIsAccessibilityFocused(View view) { AccessibilityNodeInfoCompat node = createNodeInfoFromView(view); boolean isAccessibilityFocused = node.isAccessibilityFocused(); node.recycle(); return isAccessibilityFocused; } public static boolean getIgnored(View view) { int important = ViewCompat.getImportantForAccessibility(view); if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO || important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) { return true; } // Go all the way up the tree to make sure no parent has hidden its descendants ViewParent parent = view.getParent(); while (parent instanceof View) { if (ViewCompat.getImportantForAccessibility((View) parent) == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) { return true; } parent = parent.getParent(); } AccessibilityNodeInfoCompat node = createNodeInfoFromView(view); try { if (!node.isVisibleToUser()) { return true; } if (AccessibilityUtil.isAccessibilityFocusable(node, view)) { if (node.getChildCount() <= 0) { // Leaves that are accessibility focusable are never ignored, even if they don't have a // speakable description return false; } else if (AccessibilityUtil.isSpeakingNode(node, view)) { // Node is focusable and has something to speak return false; } // Node is focusable and has nothing to speak return true; } // If this node has no focusable ancestors, but it still has text, // then it should receive focus from navigation and be read aloud. if (!AccessibilityUtil.hasFocusableAncestor(node, view) && AccessibilityUtil.hasText(node)) { return false; } return true; } finally { node.recycle(); } } public static String getIgnoredReasons(View view) { int important = ViewCompat.getImportantForAccessibility(view); if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO) { return "View has importantForAccessibility set to 'NO'."; } if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) { return "View has importantForAccessibility set to 'NO_HIDE_DESCENDANTS'."; } ViewParent parent = view.getParent(); while (parent instanceof View) { if (ViewCompat.getImportantForAccessibility((View) parent) == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) { return "An ancestor View has importantForAccessibility set to 'NO_HIDE_DESCENDANTS'."; } parent = parent.getParent(); } AccessibilityNodeInfoCompat node = createNodeInfoFromView(view); try { if (!node.isVisibleToUser()) { return "View is not visible."; } if (AccessibilityUtil.isAccessibilityFocusable(node, view)) { return "View is actionable, but has no description."; } if (AccessibilityUtil.hasText(node)) { return "View is not actionable, and an ancestor View has co-opted its description."; } return "View is not actionable and has no description."; } finally { node.recycle(); } } @Nullable public static String getFocusableReasons(View view) { AccessibilityNodeInfoCompat node = createNodeInfoFromView(view); try { boolean hasText = AccessibilityUtil.hasText(node); boolean isCheckable = node.isCheckable(); boolean hasNonActionableSpeakingDescendants = AccessibilityUtil.hasNonActionableSpeakingDescendants(node, view); if (AccessibilityUtil.isActionableForAccessibility(node)) { if (node.getChildCount() <= 0) { return "View is actionable and has no children."; } else if (hasText) { return "View is actionable and has a description."; } else if (isCheckable) { return "View is actionable and checkable."; } else if (hasNonActionableSpeakingDescendants) { return "View is actionable and has non-actionable descendants with descriptions."; } } if (AccessibilityUtil.isTopLevelScrollItem(node, view)) { if (hasText) { return "View is a direct child of a scrollable container and has a description."; } else if (isCheckable) { return "View is a direct child of a scrollable container and is checkable."; } else if (hasNonActionableSpeakingDescendants) { return "View is a direct child of a scrollable container and has non-actionable " + "descendants with descriptions."; } } if (hasText) { return "View has a description and is not actionable, but has no actionable ancestor."; } return null; } finally { node.recycle(); } } @Nullable public static String getActions(View view) { AccessibilityNodeInfoCompat node = createNodeInfoFromView(view); try { final StringBuilder actionLabels = new StringBuilder(); final String separator = ", "; for (AccessibilityActionCompat action : node.getActionList()) { if (actionLabels.length() > 0) { actionLabels.append(separator); } switch (action.getId()) { case AccessibilityNodeInfoCompat.ACTION_FOCUS: actionLabels.append("focus"); break; case AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS: actionLabels.append("clear-focus"); break; case AccessibilityNodeInfoCompat.ACTION_SELECT: actionLabels.append("select"); break; case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION: actionLabels.append("clear-selection"); break; case AccessibilityNodeInfoCompat.ACTION_CLICK: actionLabels.append("click"); break; case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK: actionLabels.append("long-click"); break; case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS: actionLabels.append("accessibility-focus"); break; case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS: actionLabels.append("clear-accessibility-focus"); break; case AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: actionLabels.append("next-at-movement-granularity"); break; case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: actionLabels.append("previous-at-movement-granularity"); break; case AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT: actionLabels.append("next-html-element"); break; case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT: actionLabels.append("previous-html-element"); break; case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD: actionLabels.append("scroll-forward"); break; case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD: actionLabels.append("scroll-backward"); break; case AccessibilityNodeInfoCompat.ACTION_CUT: actionLabels.append("cut"); break; case AccessibilityNodeInfoCompat.ACTION_COPY: actionLabels.append("copy"); break; case AccessibilityNodeInfoCompat.ACTION_PASTE: actionLabels.append("paste"); break; case AccessibilityNodeInfoCompat.ACTION_SET_SELECTION: actionLabels.append("set-selection"); break; default: CharSequence label = action.getLabel(); if (label != null) { actionLabels.append(label); } else { actionLabels.append("unknown"); } break; } } return actionLabels.length() > 0 ? actionLabels.toString() : null; } finally { node.recycle(); } } @Nullable public static CharSequence getDescription(View view) { AccessibilityNodeInfoCompat node = createNodeInfoFromView(view); try { CharSequence contentDescription = node.getContentDescription(); CharSequence nodeText = node.getText(); boolean hasNodeText = !TextUtils.isEmpty(nodeText); boolean isEditText = view instanceof EditText; // EditText's prioritize their own text content over a contentDescription if (!TextUtils.isEmpty(contentDescription) && (!isEditText || !hasNodeText)) { return contentDescription; } if (hasNodeText) { return nodeText; } // If there are child views and no contentDescription the text of all non-focusable children, // comma separated, becomes the description. if (view instanceof ViewGroup) { final StringBuilder concatChildDescription = new StringBuilder(); final String separator = ", "; ViewGroup viewGroup = (ViewGroup) view; for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) { final View child = viewGroup.getChildAt(i); AccessibilityNodeInfoCompat childNodeInfo = AccessibilityNodeInfoCompat.obtain(); ViewCompat.onInitializeAccessibilityNodeInfo(child, childNodeInfo); CharSequence childNodeDescription = null; if (AccessibilityUtil.isSpeakingNode(childNodeInfo, child) && !AccessibilityUtil.isAccessibilityFocusable(childNodeInfo, child)) { childNodeDescription = getDescription(child); } if (!TextUtils.isEmpty(childNodeDescription)) { if (concatChildDescription.length() > 0) { concatChildDescription.append(separator); } concatChildDescription.append(childNodeDescription); } childNodeInfo.recycle(); } return concatChildDescription.length() > 0 ? concatChildDescription.toString() : null; } return null; } finally { node.recycle(); } } }