Initial commit 🎉

fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2
Co-authored-by: Sebastian McKenzie <sebmck@fb.com>
Co-authored-by: John Knox <jknox@fb.com>
Co-authored-by: Emil Sjölander <emilsj@fb.com>
Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

View File

@@ -0,0 +1,383 @@
/*
* 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 android.graphics.Rect;
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 com.facebook.sonar.plugins.inspector.descriptors.utils.AccessibilityRoleUtil.AccessibilityRole;
import java.util.List;
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 class AccessibilityEvaluationUtil {
private AccessibilityEvaluationUtil() {}
/**
* 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) {
return node != null
&& node.getCollectionInfo() == null
&& (!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;
}
final 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;
}
final ViewGroup viewGroup = (ViewGroup) view;
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
final View childView = viewGroup.getChildAt(i);
if (childView == null) {
continue;
}
final AccessibilityNodeInfoCompat childNode = AccessibilityNodeInfoCompat.obtain();
try {
ViewCompat.onInitializeAccessibilityNodeInfo(childView, childNode);
if (!node.isVisibleToUser()) {
continue;
}
if (isAccessibilityFocusable(childNode, childView)) {
continue;
}
if (isSpeakingNode(childNode, childView)) {
return true;
}
} finally {
if (childNode != null) {
childNode.recycle();
}
}
}
return false;
}
/**
* Determines if the provided {@link View} and {@link AccessibilityNodeInfoCompat} meet the
* criteria for gaining accessibility focus.
*
* <p>Note: this is evaluating general focusability by accessibility services, and does not mean
* this view will be guaranteed to be focused by specific services such as Talkback. For Talkback
* focusability, see {@link #isTalkbackFocusable(View)}
*
* @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;
}
final View parent = (View) ViewCompat.getParentForAccessibility(view);
if (parent == null) {
return false;
}
if (node.isScrollable()) {
return true;
}
final List actionList = node.getActionList();
if (actionList.contains(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD)
|| actionList.contains(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD)) {
return true;
}
// Top-level items in a scrolling pager are actually two levels down since the first
// level items in pagers are the pages themselves.
View grandparent = (View) ViewCompat.getParentForAccessibility(parent);
if (grandparent != null
&& AccessibilityRoleUtil.getRole(grandparent) == AccessibilityRole.PAGER) {
return true;
}
AccessibilityRole parentRole = AccessibilityRoleUtil.getRole(parent);
return parentRole == AccessibilityRole.LIST
|| parentRole == AccessibilityRole.GRID
|| parentRole == AccessibilityRole.SCROLL_VIEW
|| parentRole == AccessibilityRole.HORIZONTAL_SCROLL_VIEW;
}
/**
* 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;
}
final 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;
}
final ViewParent parentView = ViewCompat.getParentForAccessibility(view);
if (!(parentView instanceof View)) {
return false;
}
final AccessibilityNodeInfoCompat parentNode = AccessibilityNodeInfoCompat.obtain();
try {
ViewCompat.onInitializeAccessibilityNodeInfo((View) parentView, parentNode);
if (parentNode == null) {
return false;
}
if (hasEqualBoundsToViewRoot(parentNode, (View) parentView)
&& parentNode.getChildCount() > 0) {
return false;
}
if (isAccessibilityFocusable(parentNode, (View) parentView)) {
return true;
}
if (hasFocusableAncestor(parentNode, (View) parentView)) {
return true;
}
} finally {
parentNode.recycle();
}
return false;
}
/**
* Returns whether a one given view is a descendant of another.
*
* @param view The {@link View} to evaluate
* @param potentialAncestor The potential ancestor {@link View}
* @return {@code true} if view is a descendant of potentialAncestor
*/
private static boolean viewIsDescendant(View view, View potentialAncestor) {
ViewParent parent = view.getParent();
while (parent != null) {
if (parent == potentialAncestor) {
return true;
}
parent = parent.getParent();
}
return false;
}
/**
* Returns whether a View has the same size and position as its View Root.
*
* @param view The {@link View} to evaluate
* @return {@code true} if view has equal bounds
*/
public static boolean hasEqualBoundsToViewRoot(AccessibilityNodeInfoCompat node, View view) {
AndroidRootResolver rootResolver = new AndroidRootResolver();
List<AndroidRootResolver.Root> roots = rootResolver.listActiveRoots();
if (roots != null) {
for (AndroidRootResolver.Root root : roots) {
if (view == root.view) {
return true;
}
if (viewIsDescendant(view, root.view)) {
Rect nodeBounds = new Rect();
node.getBoundsInScreen(nodeBounds);
Rect viewRootBounds = new Rect();
viewRootBounds.set(
root.param.x,
root.param.y,
root.param.width + root.param.x,
root.param.height + root.param.y);
return nodeBounds.equals(viewRootBounds);
}
}
}
return false;
}
/**
* Returns whether a given {@link View} will be focusable by Google's TalkBack screen reader.
*
* @param view The {@link View} to evaluate.
* @return {@code boolean} if the view will be ignored by TalkBack.
*/
public static boolean isTalkbackFocusable(View view) {
if (view == null) {
return false;
}
final int important = ViewCompat.getImportantForAccessibility(view);
if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO
|| important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
// 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 false;
}
parent = parent.getParent();
}
final AccessibilityNodeInfoCompat node = ViewAccessibilityHelper.createNodeInfoFromView(view);
if (node == null) {
return false;
}
// Non-leaf nodes identical in size to their View Root should not be focusable.
if (hasEqualBoundsToViewRoot(node, view) && node.getChildCount() > 0) {
return false;
}
try {
if (!node.isVisibleToUser()) {
return false;
}
if (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 true;
} else if (isSpeakingNode(node, view)) {
// Node is focusable and has something to speak
return true;
}
// Node is focusable and has nothing to speak
return false;
}
// if view is not accessibility focusable, it needs to have text and no focusable ancestors.
if (!hasText(node)) {
return false;
}
if (!hasFocusableAncestor(node, view)) {
return true;
}
return false;
} finally {
node.recycle();
}
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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 android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import javax.annotation.Nullable;
/**
* Utility class that handles the addition of a "role" for accessibility to either a View or
* AccessibilityNodeInfo.
*/
public class AccessibilityRoleUtil {
/**
* These roles are defined by Google's TalkBack screen reader, and this list should be kept up to
* date with their implementation. Details can be seen in their source code here:
*
* <p>https://github.com/google/talkback/blob/master/utils/src/main/java/Role.java
*/
public enum AccessibilityRole {
NONE(null),
BUTTON("android.widget.Button"),
CHECK_BOX("android.widget.CompoundButton"),
DROP_DOWN_LIST("android.widget.Spinner"),
EDIT_TEXT("android.widget.EditText"),
GRID("android.widget.GridView"),
IMAGE("android.widget.ImageView"),
IMAGE_BUTTON("android.widget.ImageView"),
LIST("android.widget.AbsListView"),
PAGER("android.support.v4.view.ViewPager"),
RADIO_BUTTON("android.widget.RadioButton"),
SEEK_CONTROL("android.widget.SeekBar"),
SWITCH("android.widget.Switch"),
TAB_BAR("android.widget.TabWidget"),
TOGGLE_BUTTON("android.widget.ToggleButton"),
VIEW_GROUP("android.view.ViewGroup"),
WEB_VIEW("android.webkit.WebView"),
CHECKED_TEXT_VIEW("android.widget.CheckedTextView"),
PROGRESS_BAR("android.widget.ProgressBar"),
ACTION_BAR_TAB("android.app.ActionBar$Tab"),
DRAWER_LAYOUT("android.support.v4.widget.DrawerLayout"),
SLIDING_DRAWER("android.widget.SlidingDrawer"),
ICON_MENU("com.android.internal.view.menu.IconMenuView"),
TOAST("android.widget.Toast$TN"),
DATE_PICKER_DIALOG("android.app.DatePickerDialog"),
TIME_PICKER_DIALOG("android.app.TimePickerDialog"),
DATE_PICKER("android.widget.DatePicker"),
TIME_PICKER("android.widget.TimePicker"),
NUMBER_PICKER("android.widget.NumberPicker"),
SCROLL_VIEW("android.widget.ScrollView"),
HORIZONTAL_SCROLL_VIEW("android.widget.HorizontalScrollView"),
KEYBOARD_KEY("android.inputmethodservice.Keyboard$Key");
@Nullable private final String mValue;
AccessibilityRole(String type) {
mValue = type;
}
@Nullable
public String getValue() {
return mValue;
}
public static AccessibilityRole fromValue(String value) {
for (AccessibilityRole role : AccessibilityRole.values()) {
if (role.getValue() != null && role.getValue().equals(value)) {
return role;
}
}
return AccessibilityRole.NONE;
}
}
private AccessibilityRoleUtil() {
// No instances
}
public static AccessibilityRole getRole(View view) {
AccessibilityNodeInfoCompat nodeInfo = AccessibilityNodeInfoCompat.obtain();
ViewCompat.onInitializeAccessibilityNodeInfo(view, nodeInfo);
AccessibilityRole role = getRole(nodeInfo);
nodeInfo.recycle();
return role;
}
public static AccessibilityRole getRole(AccessibilityNodeInfo nodeInfo) {
return getRole(new AccessibilityNodeInfoCompat(nodeInfo));
}
public static AccessibilityRole getRole(AccessibilityNodeInfoCompat nodeInfo) {
AccessibilityRole role = AccessibilityRole.fromValue((String) nodeInfo.getClassName());
if (role.equals(AccessibilityRole.IMAGE_BUTTON) || role.equals(AccessibilityRole.IMAGE)) {
return nodeInfo.isClickable() ? AccessibilityRole.IMAGE_BUTTON : AccessibilityRole.IMAGE;
}
if (role.equals(AccessibilityRole.NONE)) {
AccessibilityNodeInfoCompat.CollectionInfoCompat collection = nodeInfo.getCollectionInfo();
if (collection != null) {
// RecyclerView will be classified as a list or grid.
if (collection.getRowCount() > 1 && collection.getColumnCount() > 1) {
return AccessibilityRole.GRID;
} else {
return AccessibilityRole.LIST;
}
}
}
return role;
}
}

View File

@@ -0,0 +1,357 @@
/*
* 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 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();
}
/**
* 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));
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* 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.view.WindowManager.LayoutParams;
import android.os.Build;
import android.view.View;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.annotation.Nullable;
public final class AndroidRootResolver {
private static final String WINDOW_MANAGER_IMPL_CLAZZ = "android.view.WindowManagerImpl";
private static final String WINDOW_MANAGER_GLOBAL_CLAZZ = "android.view.WindowManagerGlobal";
private static final String VIEWS_FIELD = "mViews";
private static final String WINDOW_PARAMS_FIELD = "mParams";
private static final String GET_DEFAULT_IMPL = "getDefault";
private static final String GET_GLOBAL_INSTANCE = "getInstance";
private boolean initialized;
private Object windowManagerObj;
private Field viewsField;
private Field paramsField;
public static class Root {
public final View view;
public final LayoutParams param;
private Root(View view, LayoutParams param) {
this.view = view;
this.param = param;
}
}
public @Nullable List<Root> listActiveRoots() {
if (!initialized) {
initialize();
}
if (null == windowManagerObj) {
return null;
}
if (null == viewsField) {
return null;
}
if (null == paramsField) {
return null;
}
List<View> views = null;
List<LayoutParams> params = null;
try {
if (Build.VERSION.SDK_INT < 19) {
views = Arrays.asList((View[]) viewsField.get(windowManagerObj));
params = Arrays.asList((LayoutParams[]) paramsField.get(windowManagerObj));
} else {
views = (List<View>) viewsField.get(windowManagerObj);
params = (List<LayoutParams>) paramsField.get(windowManagerObj);
}
} catch (RuntimeException | IllegalAccessException re) {
return null;
}
List<Root> roots = new ArrayList<>();
for (int i = 0, stop = views.size(); i < stop; i++) {
roots.add(new Root(views.get(i), params.get(i)));
}
return roots;
}
private void initialize() {
initialized = true;
String accessClass =
Build.VERSION.SDK_INT > 16 ? WINDOW_MANAGER_GLOBAL_CLAZZ : WINDOW_MANAGER_IMPL_CLAZZ;
String instanceMethod = Build.VERSION.SDK_INT > 16 ? GET_GLOBAL_INSTANCE : GET_DEFAULT_IMPL;
try {
Class<?> clazz = Class.forName(accessClass);
Method getMethod = clazz.getMethod(instanceMethod);
windowManagerObj = getMethod.invoke(null);
viewsField = clazz.getDeclaredField(VIEWS_FIELD);
viewsField.setAccessible(true);
paramsField = clazz.getDeclaredField(WINDOW_PARAMS_FIELD);
paramsField.setAccessible(true);
} catch (InvocationTargetException | IllegalAccessException | RuntimeException | NoSuchMethodException | NoSuchFieldException | ClassNotFoundException ignored) {
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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 com.facebook.sonar.plugins.inspector.InspectorValue.Type.Enum;
import android.support.v4.util.SimpleArrayMap;
import com.facebook.sonar.plugins.inspector.InspectorValue;
public class EnumMapping {
private final SimpleArrayMap<String, Integer> mMapping = new SimpleArrayMap<>();
private final String mDefaultKey;
public EnumMapping(String defaultKey) {
mDefaultKey = defaultKey;
}
public void put(String s, int i) {
mMapping.put(s, i);
}
public InspectorValue get(final int i) {
return get(i, true);
}
public InspectorValue get(final int i, final boolean mutable) {
for (int ii = 0, count = mMapping.size(); ii < count; ii++) {
if (mMapping.valueAt(ii) == i) {
return mutable
? InspectorValue.mutable(Enum, mMapping.keyAt(ii))
: InspectorValue.immutable(Enum, mMapping.keyAt(ii));
}
}
return mutable
? InspectorValue.mutable(Enum, mDefaultKey)
: InspectorValue.immutable(Enum, mDefaultKey);
}
public int get(String s) {
if (mMapping.containsKey(s)) {
return mMapping.get(s);
}
return mMapping.get(mDefaultKey);
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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 android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.view.View;
import javax.annotation.Nullable;
/** Class that helps with accessibility by providing useful methods. */
public final class ViewAccessibilityHelper {
/**
* Creates and returns an {@link AccessibilityNodeInfoCompat} from the the provided {@link View}.
* Note: This does not handle recycling of the {@link AccessibilityNodeInfoCompat}.
*
* @param view The {@link View} to create the {@link AccessibilityNodeInfoCompat} from.
* @return {@link AccessibilityNodeInfoCompat}
*/
@Nullable
public static AccessibilityNodeInfoCompat createNodeInfoFromView(View view) {
if (view == null) {
return null;
}
final AccessibilityNodeInfoCompat nodeInfo = AccessibilityNodeInfoCompat.obtain();
// For some unknown reason, Android seems to occasionally throw a NPE from
// onInitializeAccessibilityNodeInfo.
try {
ViewCompat.onInitializeAccessibilityNodeInfo(view, nodeInfo);
} catch (NullPointerException e) {
if (nodeInfo != null) {
nodeInfo.recycle();
}
return null;
}
return nodeInfo;
}
}