Summary: Switches the tree to use the view hierarchy for Litho nodes rather than Litho component hierarchy since Accessibility services interact with the views rendered. Includes a few more properties in the accessibility sidebar and updates to the segmented sidebar based on derived/non-derived properties for all views. Also adds functions to the AccessibilityUtil to be able to work on the accessibility sidebar while still leaving the non-accessibility sidebar unchanged. Eventually the accessibility panel will be removed from 'normal' mode and the original functions will no longer be necessary. Reviewed By: blavalla Differential Revision: D8881739 fbshipit-source-id: 9ce37e8f18025538cba2c86c0895ee38d13d024b
487 lines
19 KiB
Java
487 lines
19 KiB
Java
/*
|
|
* Copyright (c) 2018-present, Facebook, Inc.
|
|
*
|
|
* This source code is licensed under the MIT license found in the LICENSE
|
|
* file in the root directory of this source tree.
|
|
*
|
|
*/
|
|
|
|
package com.facebook.sonar.plugins.inspector.descriptors.utils;
|
|
|
|
import static android.content.Context.ACCESSIBILITY_SERVICE;
|
|
|
|
import android.content.Context;
|
|
import android.graphics.Rect;
|
|
import android.os.Build;
|
|
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.view.accessibility.AccessibilityManager;
|
|
import android.widget.EditText;
|
|
import com.facebook.sonar.core.SonarArray;
|
|
import com.facebook.sonar.core.SonarObject;
|
|
import com.facebook.sonar.plugins.inspector.InspectorValue;
|
|
import javax.annotation.Nullable;
|
|
|
|
/**
|
|
* 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() {}
|
|
|
|
public static final EnumMapping sAccessibilityActionMapping =
|
|
new EnumMapping("UNKNOWN") {
|
|
{
|
|
put("FOCUS", AccessibilityNodeInfoCompat.ACTION_FOCUS);
|
|
put("CLEAR_FOCUS", AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS);
|
|
put("SELECT", AccessibilityNodeInfoCompat.ACTION_SELECT);
|
|
put("CLEAR_SELECTION", AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION);
|
|
put("CLICK", AccessibilityNodeInfoCompat.ACTION_CLICK);
|
|
put("LONG_CLICK", AccessibilityNodeInfoCompat.ACTION_LONG_CLICK);
|
|
put("ACCESSIBILITY_FOCUS", AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
|
|
put(
|
|
"CLEAR_ACCESSIBILITY_FOCUS",
|
|
AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
|
|
put(
|
|
"NEXT_AT_MOVEMENT_GRANULARITY",
|
|
AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
|
|
put(
|
|
"PREVIOUS_AT_MOVEMENT_GRANULARITY",
|
|
AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
|
|
put("NEXT_HTML_ELEMENT", AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT);
|
|
put("PREVIOUS_HTML_ELEMENT", AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT);
|
|
put("SCROLL_FORWARD", AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
|
|
put("SCROLL_BACKWARD", AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
|
|
put("CUT", AccessibilityNodeInfoCompat.ACTION_CUT);
|
|
put("COPY", AccessibilityNodeInfoCompat.ACTION_COPY);
|
|
put("PASTE", AccessibilityNodeInfoCompat.ACTION_PASTE);
|
|
put("SET_SELECTION", AccessibilityNodeInfoCompat.ACTION_SET_SELECTION);
|
|
put("SET_SELECTION", AccessibilityNodeInfoCompat.ACTION_SET_SELECTION);
|
|
put("EXPAND", AccessibilityNodeInfoCompat.ACTION_EXPAND);
|
|
put("COLLAPSE", AccessibilityNodeInfoCompat.ACTION_COLLAPSE);
|
|
put("DISMISS", AccessibilityNodeInfoCompat.ACTION_DISMISS);
|
|
put("SET_TEXT", AccessibilityNodeInfoCompat.ACTION_SET_TEXT);
|
|
}
|
|
};
|
|
|
|
public static final EnumMapping sImportantForAccessibilityMapping =
|
|
new EnumMapping("AUTO") {
|
|
{
|
|
put("AUTO", View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
|
|
put("NO", View.IMPORTANT_FOR_ACCESSIBILITY_NO);
|
|
put("YES", View.IMPORTANT_FOR_ACCESSIBILITY_YES);
|
|
put("NO_HIDE_DESCENDANTS", View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Given a {@link Context}, determine if any accessibility service is running.
|
|
*
|
|
* @param context The {@link Context} used to get the {@link AccessibilityManager}.
|
|
* @return {@code true} if an accessibility service is currently running.
|
|
*/
|
|
public static boolean isAccessibilityEnabled(Context context) {
|
|
return ((AccessibilityManager) context.getSystemService(ACCESSIBILITY_SERVICE)).isEnabled();
|
|
}
|
|
|
|
/**
|
|
* Returns a sentence describing why a given {@link View} will be ignored by Google's TalkBack
|
|
* screen reader.
|
|
*
|
|
* @param view The {@link View} to evaluate.
|
|
* @return {@code String} describing why a {@link View} is ignored.
|
|
*/
|
|
public static String getTalkbackIgnoredReasons(View view) {
|
|
final 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();
|
|
}
|
|
|
|
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
|
|
if (node == null) {
|
|
return "AccessibilityNodeInfo cannot be found.";
|
|
}
|
|
|
|
try {
|
|
if (AccessibilityEvaluationUtil.hasEqualBoundsToViewRoot(node, view)) {
|
|
return "View has the same dimensions as the View Root.";
|
|
}
|
|
|
|
if (!node.isVisibleToUser()) {
|
|
return "View is not visible.";
|
|
}
|
|
|
|
if (AccessibilityEvaluationUtil.isAccessibilityFocusable(node, view)) {
|
|
return "View is actionable, but has no description.";
|
|
}
|
|
|
|
if (AccessibilityEvaluationUtil.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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a sentence describing why a given {@link View} will be focusable by Google's TalkBack
|
|
* screen reader.
|
|
*
|
|
* @param view The {@link View} to evaluate.
|
|
* @return {@code String} describing why a {@link View} is focusable.
|
|
*/
|
|
@Nullable
|
|
public static String getTalkbackFocusableReasons(View view) {
|
|
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
|
|
if (node == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
final boolean hasText = AccessibilityEvaluationUtil.hasText(node);
|
|
final boolean isCheckable = node.isCheckable();
|
|
final boolean hasNonActionableSpeakingDescendants =
|
|
AccessibilityEvaluationUtil.hasNonActionableSpeakingDescendants(node, view);
|
|
|
|
if (AccessibilityEvaluationUtil.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 (AccessibilityEvaluationUtil.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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates the text that Gogole's TalkBack screen reader will read aloud for a given {@link View}.
|
|
* This may be any combination of the {@link View}'s {@code text}, {@code contentDescription}, and
|
|
* the {@code text} and {@code contentDescription} of any ancestor {@link View}.
|
|
*
|
|
* <p>Note: This string does not include any additional semantic information that Talkback will
|
|
* read, such as "Button", or "disabled".
|
|
*
|
|
* @param view The {@link View} to evaluate.
|
|
* @return {@code String} describing why a {@link View} is focusable.
|
|
*/
|
|
@Nullable
|
|
public static CharSequence getTalkbackDescription(View view) {
|
|
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
|
|
if (node == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
final CharSequence contentDescription = node.getContentDescription();
|
|
final CharSequence nodeText = node.getText();
|
|
|
|
final boolean hasNodeText = !TextUtils.isEmpty(nodeText);
|
|
final 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 = ", ";
|
|
final ViewGroup viewGroup = (ViewGroup) view;
|
|
|
|
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
|
|
final View child = viewGroup.getChildAt(i);
|
|
|
|
final AccessibilityNodeInfoCompat childNodeInfo = AccessibilityNodeInfoCompat.obtain();
|
|
ViewCompat.onInitializeAccessibilityNodeInfo(child, childNodeInfo);
|
|
|
|
CharSequence childNodeDescription = null;
|
|
if (AccessibilityEvaluationUtil.isSpeakingNode(childNodeInfo, child)
|
|
&& !AccessibilityEvaluationUtil.isAccessibilityFocusable(childNodeInfo, child)) {
|
|
childNodeDescription = getTalkbackDescription(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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a {@link SonarObject} of useful properties of AccessibilityNodeInfo, to be shown in the
|
|
* Sonar Layout Inspector. All properties are immutable since they are all derived from various
|
|
* {@link View} properties.
|
|
*
|
|
* @param view The {@link View} to derive the AccessibilityNodeInfo properties from.
|
|
* @return {@link SonarObject} containing the properties.
|
|
*/
|
|
@Nullable
|
|
public static SonarObject getAccessibilityNodeInfoProperties(View view) {
|
|
final AccessibilityNodeInfoCompat nodeInfo =
|
|
ViewAccessibilityHelper.createNodeInfoFromView(view);
|
|
if (nodeInfo == null) {
|
|
return null;
|
|
}
|
|
|
|
final SonarObject.Builder nodeInfoProps = new SonarObject.Builder();
|
|
final Rect bounds = new Rect();
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
final SonarArray.Builder actionsArrayBuilder = new SonarArray.Builder();
|
|
for (AccessibilityNodeInfoCompat.AccessibilityActionCompat action :
|
|
nodeInfo.getActionList()) {
|
|
final String actionLabel = (String) action.getLabel();
|
|
if (actionLabel != null) {
|
|
actionsArrayBuilder.put(actionLabel);
|
|
} else {
|
|
actionsArrayBuilder.put(
|
|
AccessibilityUtil.sAccessibilityActionMapping.get(action.getId(), false));
|
|
}
|
|
}
|
|
nodeInfoProps.put("actions", actionsArrayBuilder.build());
|
|
}
|
|
|
|
nodeInfoProps
|
|
.put("clickable", nodeInfo.isClickable())
|
|
.put("content-description", nodeInfo.getContentDescription())
|
|
.put("text", nodeInfo.getText())
|
|
.put("focused", nodeInfo.isAccessibilityFocused())
|
|
.put("long-clickable", nodeInfo.isLongClickable())
|
|
.put("focusable", nodeInfo.isFocusable());
|
|
|
|
nodeInfo.getBoundsInParent(bounds);
|
|
nodeInfoProps.put(
|
|
"parent-bounds",
|
|
new SonarObject.Builder()
|
|
.put("width", bounds.width())
|
|
.put("height", bounds.height())
|
|
.put("top", bounds.top)
|
|
.put("left", bounds.left)
|
|
.put("bottom", bounds.bottom)
|
|
.put("right", bounds.right));
|
|
|
|
nodeInfo.getBoundsInScreen(bounds);
|
|
nodeInfoProps.put(
|
|
"screen-bounds",
|
|
new SonarObject.Builder()
|
|
.put("width", bounds.width())
|
|
.put("height", bounds.height())
|
|
.put("top", bounds.top)
|
|
.put("left", bounds.left)
|
|
.put("bottom", bounds.bottom)
|
|
.put("right", bounds.right));
|
|
|
|
nodeInfo.recycle();
|
|
|
|
return nodeInfoProps.build();
|
|
}
|
|
|
|
/**
|
|
* Creates a {@link SonarObject} of useful properties of AccessibilityNodeInfo, to be shown in the
|
|
* Sonar Layout Inspector extension. All properties are immutable since they are all derived from
|
|
* various {@link View} properties. This is a more complete list than
|
|
* getAccessibilityNodeInfoProperties returns.
|
|
*
|
|
* @param view The {@link View} to derive the AccessibilityNodeInfo properties from.
|
|
* @return {@link SonarObject} containing the properties.
|
|
*/
|
|
@Nullable
|
|
public static SonarObject getAXNodeInfoProperties(View view) {
|
|
final AccessibilityNodeInfoCompat nodeInfo =
|
|
ViewAccessibilityHelper.createNodeInfoFromView(view);
|
|
if (nodeInfo == null) {
|
|
return null;
|
|
}
|
|
|
|
final SonarObject.Builder nodeInfoProps = new SonarObject.Builder();
|
|
final Rect bounds = new Rect();
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
final SonarArray.Builder actionsArrayBuilder = new SonarArray.Builder();
|
|
for (AccessibilityNodeInfoCompat.AccessibilityActionCompat action :
|
|
nodeInfo.getActionList()) {
|
|
final String actionLabel = (String) action.getLabel();
|
|
if (actionLabel != null) {
|
|
actionsArrayBuilder.put(actionLabel);
|
|
} else {
|
|
actionsArrayBuilder.put(
|
|
AccessibilityUtil.sAccessibilityActionMapping.get(action.getId(), false));
|
|
}
|
|
}
|
|
nodeInfoProps.put("actions", actionsArrayBuilder.build());
|
|
}
|
|
|
|
nodeInfoProps
|
|
.put("checkable", nodeInfo.isCheckable())
|
|
.put("checked", nodeInfo.isChecked())
|
|
.put("clickable", nodeInfo.isClickable())
|
|
.put("content-description", nodeInfo.getContentDescription())
|
|
.put("content-invalid", nodeInfo.isContentInvalid())
|
|
.put("context-clickable", nodeInfo.isContextClickable())
|
|
.put("dismissable", nodeInfo.isDismissable())
|
|
.put("drawing-order", nodeInfo.getDrawingOrder())
|
|
.put("editable", nodeInfo.isEditable())
|
|
.put("enabled", nodeInfo.isEnabled())
|
|
.put("focusable", nodeInfo.isFocusable())
|
|
.put("focused", nodeInfo.isAccessibilityFocused())
|
|
.put("important-for-accessibility", nodeInfo.isImportantForAccessibility())
|
|
.put("long-clickable", nodeInfo.isLongClickable())
|
|
.put("multiline", nodeInfo.isMultiLine())
|
|
.put("password", nodeInfo.isPassword())
|
|
.put("scrollable", nodeInfo.isScrollable())
|
|
.put("selected", nodeInfo.isSelected())
|
|
.put("text", nodeInfo.getText())
|
|
.put("visible-to-user", nodeInfo.isVisibleToUser());
|
|
|
|
nodeInfo.getBoundsInParent(bounds);
|
|
nodeInfoProps.put(
|
|
"parent-bounds",
|
|
new SonarObject.Builder()
|
|
.put("width", bounds.width())
|
|
.put("height", bounds.height())
|
|
.put("top", bounds.top)
|
|
.put("left", bounds.left)
|
|
.put("bottom", bounds.bottom)
|
|
.put("right", bounds.right));
|
|
|
|
nodeInfo.getBoundsInScreen(bounds);
|
|
nodeInfoProps.put(
|
|
"screen-bounds",
|
|
new SonarObject.Builder()
|
|
.put("width", bounds.width())
|
|
.put("height", bounds.height())
|
|
.put("top", bounds.top)
|
|
.put("left", bounds.left)
|
|
.put("bottom", bounds.bottom)
|
|
.put("right", bounds.right));
|
|
|
|
nodeInfo.recycle();
|
|
|
|
return nodeInfoProps.build();
|
|
}
|
|
|
|
/**
|
|
* Modifies a {@link SonarObject.Builder} to add Talkback-specific Accessibiltiy properties to be
|
|
* shown in the Sonar Layout Inspector.
|
|
*
|
|
* @param props The {@link SonarObject.Builder} to add the properties to.
|
|
* @param view The {@link View} to derive the properties from.
|
|
*/
|
|
public static void addTalkbackProperties(SonarObject.Builder props, View view) {
|
|
if (!AccessibilityEvaluationUtil.isTalkbackFocusable(view)) {
|
|
props
|
|
.put("talkback-ignored", true)
|
|
.put("talkback-ignored-reasons", getTalkbackIgnoredReasons(view));
|
|
} else {
|
|
props
|
|
.put("talkback-focusable", true)
|
|
.put("talkback-focusable-reasons", getTalkbackFocusableReasons(view))
|
|
.put("talkback-description", getTalkbackDescription(view));
|
|
}
|
|
}
|
|
|
|
public static SonarObject getViewAXData(View view) {
|
|
final SonarObject.Builder props = new SonarObject.Builder();
|
|
|
|
// This needs to be an empty string to be mutable. See t20470623.
|
|
CharSequence contentDescription =
|
|
view.getContentDescription() != null ? view.getContentDescription() : "";
|
|
props.put("content-description", InspectorValue.mutable(contentDescription));
|
|
props.put("focusable", InspectorValue.mutable(view.isFocusable()));
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
|
|
props.put(
|
|
"important-for-accessibility",
|
|
AccessibilityUtil.sImportantForAccessibilityMapping.get(
|
|
view.getImportantForAccessibility()));
|
|
}
|
|
|
|
return props.build();
|
|
}
|
|
|
|
public static SonarObject getDerivedAXData(View view) {
|
|
final SonarObject.Builder props = new SonarObject.Builder();
|
|
|
|
if (!AccessibilityEvaluationUtil.isTalkbackFocusable(view)) {
|
|
String reason = getTalkbackIgnoredReasons(view);
|
|
props
|
|
.put("talkback-ignored", true)
|
|
.put("talkback-ignored-reasons", reason == null ? "" : reason);
|
|
} else {
|
|
String reason = getTalkbackFocusableReasons(view);
|
|
CharSequence description = getTalkbackDescription(view);
|
|
props
|
|
.put("talkback-focusable", true)
|
|
.put("talkback-focusable-reasons", reason == null ? "" : reason)
|
|
.put("talkback-description", description == null ? "" : description);
|
|
}
|
|
|
|
SonarObject axNodeInfo = getAXNodeInfoProperties(view);
|
|
if (axNodeInfo != null) {
|
|
props.put("node-info", axNodeInfo);
|
|
}
|
|
|
|
return props.build();
|
|
}
|
|
}
|