// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.content.browser.accessibility;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_ACCESSIBILITY_FOCUS;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLEAR_FOCUS;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_COLLAPSE;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CONTEXT_CLICK;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_COPY;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CUT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_EXPAND;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_FOCUS;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_IME_ENTER;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_LONG_CLICK;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_NEXT_HTML_ELEMENT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PAGE_DOWN;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PAGE_LEFT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PAGE_RIGHT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PAGE_UP;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PASTE;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PREVIOUS_HTML_ELEMENT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_BACKWARD;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_DOWN;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_FORWARD;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_LEFT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_RIGHT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_UP;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_PROGRESS;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_SELECTION;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_TEXT;
import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SHOW_ON_SCREEN;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_CSS_DISPLAY;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_OFFSCREEN;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_SUPPORTED_ELEMENTS;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_BOTTOM;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_HEIGHT;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_LEFT;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_RIGHT;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_TOP;
import static org.chromium.content.browser.accessibility.AccessibilityNodeInfoBuilder.EXTRAS_KEY_UNCLIPPED_WIDTH;
import static java.lang.String.CASE_INSENSITIVE_ORDER;
import android.graphics.Rect;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Utility class for common actions involving AccessibilityNodeInfo objects. */
public class AccessibilityNodeInfoUtils {
/**
* Helper method to perform a custom toString on a given AccessibilityNodeInfo object.
*
* @param node Object to create a toString for
* @return String Custom toString result for the given object
*/
public static String toString(
AccessibilityNodeInfoCompat node, boolean includeScreenSizeDependentAttributes) {
if (node == null) return "";
StringBuilder builder = new StringBuilder();
// Print classname first, but only print content after the last period to remove redundancy.
assert node.getClassName() != null : "Classname should never be null";
assert !node.getClassName().toString().contains("\\.") : "Classname should contain periods";
String[] classNameParts = node.getClassName().toString().split("\\.");
builder.append(classNameParts[classNameParts.length - 1]);
// Print text unless it is empty (null is allowed).
if (node.getText() == null) {
builder.append(" text:\"null\"");
} else if (!node.getText().toString().isEmpty()) {
builder.append(" text:\"")
.append(node.getText().toString().replace("\n", "\\n"))
.append("\"");
}
// Print hint unless it is null or empty.
if (node.getHintText() != null && !node.getHintText().toString().isEmpty()) {
builder.append(" hint:\"").append(node.getHintText()).append("\"");
}
// Text properties - Only print when non-null.
if (node.getContentDescription() != null) {
builder.append(" contentDescription:\"")
.append(node.getContentDescription().toString().replace("\n", "\\n"))
.append("\"");
}
if (node.getPaneTitle() != null) {
builder.append(" paneTitle:\"").append(node.getPaneTitle()).append("\"");
}
if (node.getViewIdResourceName() != null) {
builder.append(" viewIdResName:\"").append(node.getViewIdResourceName()).append("\"");
}
if (node.getError() != null) {
builder.append(" error:\"").append(node.getError()).append("\"");
}
if (node.getStateDescription() != null
&& !node.getStateDescription().toString().isEmpty()) {
builder.append(" stateDescription:\"").append(node.getStateDescription()).append("\"");
}
// Boolean properties - Only print when set to true except for enabled and visibleToUser,
// which are both mostly true, so only print when they are false.
if (node.canOpenPopup()) {
builder.append(" canOpenPopUp");
}
if (node.isCheckable()) {
builder.append(" checkable");
}
if (node.isChecked()) {
builder.append(" checked");
}
if (node.isClickable()) {
builder.append(" clickable");
}
if (node.isContentInvalid()) {
builder.append(" contentInvalid");
}
if (node.isDismissable()) {
builder.append(" dismissable");
}
if (node.isEditable()) {
builder.append(" editable");
}
if (!node.isEnabled()) {
builder.append(" disabled");
}
if (node.isFocusable()) {
builder.append(" focusable");
}
if (node.isFocused()) {
builder.append(" focused");
}
if (node.isMultiLine()) {
builder.append(" multiLine");
}
if (node.isPassword()) {
builder.append(" password");
}
if (node.isScrollable() && includeScreenSizeDependentAttributes) {
builder.append(" scrollable");
}
if (node.isSelected()) {
builder.append(" selected");
}
if (!node.isVisibleToUser()) {
builder.append(" notVisibleToUser");
}
// Integer properties - Only print when not default values.
if (node.getInputType() != InputType.TYPE_NULL) {
builder.append(" inputType:").append(node.getInputType());
}
if (node.getTextSelectionStart() != -1) {
builder.append(" textSelectionStart:").append(node.getTextSelectionStart());
}
if (node.getTextSelectionEnd() != -1) {
builder.append(" textSelectionEnd:").append(node.getTextSelectionEnd());
}
if (node.getMaxTextLength() != -1) {
builder.append(" maxTextLength:").append(node.getMaxTextLength());
}
// Child objects - print for non-null cases.
if (node.getCollectionInfo() != null) {
builder.append(" CollectionInfo:").append(toString(node.getCollectionInfo()));
}
if (node.getCollectionItemInfo() != null) {
builder.append(" CollectionItemInfo:").append(toString(node.getCollectionItemInfo()));
}
if (node.getRangeInfo() != null) {
builder.append(" RangeInfo:").append(toString(node.getRangeInfo()));
}
// Actions and Bundle extras - Always print.
builder.append(" actions:")
.append(toString(node.getActionList(), includeScreenSizeDependentAttributes));
builder.append(" bundle:")
.append(toString(node.getExtras(), includeScreenSizeDependentAttributes));
// Add bounds when including screen size dependent attributes.
if (includeScreenSizeDependentAttributes) {
Rect output = new Rect();
node.getBoundsInScreen(output);
builder.append(" bounds:[")
.append(output.left)
.append(", ")
.append(output.top)
.append(" - ")
.append(output.width())
.append("x")
.append(output.height())
.append("]");
output = new Rect();
node.getBoundsInParent(output);
builder.append(" boundsInParent:[")
.append(output.left)
.append(", ")
.append(output.top)
.append(" - ")
.append(output.width())
.append("x")
.append(output.height())
.append("]");
}
return builder.toString();
}
// Various helper methods to print custom toStrings for objects.
private static String toString(AccessibilityNodeInfoCompat.CollectionInfoCompat info) {
// Only include the isHierarchical boolean if true, since it is more often false, and
// ignore selection mode, which is not set by Chrome.
String prefix = "[";
if (info.isHierarchical()) {
prefix += "hierarchical, ";
}
return String.format(
"%srows=%s, cols=%s]", prefix, info.getRowCount(), info.getColumnCount());
}
private static String toString(AccessibilityNodeInfoCompat.CollectionItemInfoCompat info) {
// Only include isHeading and isSelected if true, since both are more often false.
String prefix = "[";
if (info.isHeading()) {
prefix += "heading, ";
}
if (info.isSelected()) {
prefix += "selected, ";
}
// Only include row/col span if not equal to 1, the default value.
if (info.getRowSpan() != 1) {
prefix += String.format("rowSpan=%s, ", info.getRowSpan());
}
if (info.getColumnSpan() != 1) {
prefix += String.format("colSpan=%s, ", info.getColumnSpan());
}
return String.format(
"%srowIndex=%s, colIndex=%s]", prefix, info.getRowIndex(), info.getColumnIndex());
}
private static String toString(AccessibilityNodeInfoCompat.RangeInfoCompat info) {
// Chrome always uses the float range type, so only print values of RangeInfo.
return String.format(
"[current=%s, min=%s, max=%s]", info.getCurrent(), info.getMin(), info.getMax());
}
private static String toString(
List<AccessibilityNodeInfoCompat.AccessibilityActionCompat> actionList,
boolean includeScreenSizeDependentAttributes) {
// Sort actions list to ensure consistent output of tests.
Collections.sort(actionList, (a1, b2) -> Integer.compare(a1.getId(), b2.getId()));
List<String> actionStrings = new ArrayList<String>();
StringBuilder builder = new StringBuilder();
builder.append("[");
for (AccessibilityNodeInfoCompat.AccessibilityActionCompat action : actionList) {
// Five actions are set on all nodes, so ignore those when printing the tree.
if (action.equals(ACTION_NEXT_HTML_ELEMENT)
|| action.equals(ACTION_PREVIOUS_HTML_ELEMENT)
|| action.equals(ACTION_SHOW_ON_SCREEN)
|| action.equals(ACTION_CONTEXT_CLICK)
|| action.equals(ACTION_LONG_CLICK)) {
continue;
}
// When |includeScreenSizeDependentAttributes| is true, we include all other actions,
// so append and return early, otherwise continue to checks below.
if (includeScreenSizeDependentAttributes) {
actionStrings.add(toString(action.getId()));
continue;
}
// Scroll actions are dependent on screen size, so ignore them to reduce flakiness
if (action.equals(ACTION_SCROLL_FORWARD)
|| action.equals(ACTION_SCROLL_BACKWARD)
|| action.equals(ACTION_SCROLL_DOWN)
|| action.equals(ACTION_SCROLL_UP)
|| action.equals(ACTION_SCROLL_RIGHT)
|| action.equals(ACTION_SCROLL_LEFT)) {
continue;
}
// Page actions are dependent on screen size, so ignore them to reduce flakiness.
if (action.equals(ACTION_PAGE_UP)
|| action.equals(ACTION_PAGE_DOWN)
|| action.equals(ACTION_PAGE_LEFT)
|| action.equals(ACTION_PAGE_RIGHT)) {
continue;
}
actionStrings.add(toString(action.getId()));
}
builder.append(TextUtils.join(", ", actionStrings)).append("]");
return builder.toString();
}
public static String toString(int action) {
if (action == ACTION_NEXT_AT_MOVEMENT_GRANULARITY.getId()) {
return "NEXT";
} else if (action == ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY.getId()) {
return "PREVIOUS";
} else if (action == ACTION_SET_TEXT.getId()) {
return "SET_TEXT";
} else if (action == ACTION_PASTE.getId()) {
return "PASTE";
} else if (action == ACTION_IME_ENTER.getId()) {
return "IME_ENTER";
} else if (action == ACTION_SET_SELECTION.getId()) {
return "SET_SELECTION";
} else if (action == ACTION_CUT.getId()) {
return "CUT";
} else if (action == ACTION_COPY.getId()) {
return "COPY";
} else if (action == ACTION_SCROLL_FORWARD.getId()) {
return "SCROLL_FORWARD";
} else if (action == ACTION_SCROLL_BACKWARD.getId()) {
return "SCROLL_BACKWARD";
} else if (action == ACTION_SCROLL_UP.getId()) {
return "SCROLL_UP";
} else if (action == ACTION_PAGE_UP.getId()) {
return "PAGE_UP";
} else if (action == ACTION_SCROLL_DOWN.getId()) {
return "SCROLL_DOWN";
} else if (action == ACTION_PAGE_DOWN.getId()) {
return "PAGE_DOWN";
} else if (action == ACTION_SCROLL_LEFT.getId()) {
return "SCROLL_LEFT";
} else if (action == ACTION_PAGE_LEFT.getId()) {
return "PAGE_LEFT";
} else if (action == ACTION_SCROLL_RIGHT.getId()) {
return "SCROLL_RIGHT";
} else if (action == ACTION_PAGE_RIGHT.getId()) {
return "PAGE_RIGHT";
} else if (action == ACTION_CLEAR_FOCUS.getId()) {
return "CLEAR_FOCUS";
} else if (action == ACTION_FOCUS.getId()) {
return "FOCUS";
} else if (action == ACTION_CLEAR_ACCESSIBILITY_FOCUS.getId()) {
return "CLEAR_AX_FOCUS";
} else if (action == ACTION_ACCESSIBILITY_FOCUS.getId()) {
return "AX_FOCUS";
} else if (action == ACTION_CLICK.getId()) {
return "CLICK";
} else if (action == ACTION_EXPAND.getId()) {
return "EXPAND";
} else if (action == ACTION_COLLAPSE.getId()) {
return "COLLAPSE";
} else if (action == ACTION_SET_PROGRESS.getId()) {
return "SET_PROGRESS";
} else if (action == ACTION_LONG_CLICK.getId()) {
return "LONG_CLICK";
} else {
return "NOT_IMPLEMENTED";
}
/*
* The ACTION_LONG_CLICK click action is deliberately never be added to a node.
* These are the remaining potential actions which Chrome does not implement:
* ACTION_DISMISS, ACTION_SELECT, ACTION_CLEAR_SELECTION, ACTION_SCROLL_TO_POSITION,
* ACTION_MOVE_WINDOW, ACTION_SHOW_TOOLTIP, ACTION_HIDE_TOOLTIP, ACTION_PRESS_AND_HOLD
*/
}
private static String toString(Bundle extras, boolean includeScreenSizeDependentAttributes) {
// Sort keys to ensure consistent output of tests.
List<String> sortedKeySet = new ArrayList<String>(extras.keySet());
Collections.sort(sortedKeySet, CASE_INSENSITIVE_ORDER);
List<String> bundleStrings = new ArrayList<>();
StringBuilder builder = new StringBuilder();
builder.append("[");
for (String key : sortedKeySet) {
// Two Bundle extras are related to bounding boxes, these should be ignored so the
// tests can safely run on varying devices and not be screen-dependent, unless
// explicitly allowed for this instance.
if (!includeScreenSizeDependentAttributes
&& (key.equals(EXTRAS_KEY_UNCLIPPED_TOP)
|| key.equals(EXTRAS_KEY_UNCLIPPED_BOTTOM)
|| key.equals(EXTRAS_KEY_UNCLIPPED_LEFT)
|| key.equals(EXTRAS_KEY_UNCLIPPED_RIGHT)
|| key.equals(EXTRAS_KEY_UNCLIPPED_WIDTH)
|| key.equals(EXTRAS_KEY_UNCLIPPED_HEIGHT))) {
continue;
}
// Since every node has a few Bundle extras, and some are often empty, we will only
// print non-null and not empty values.
if (extras.get(key) == null || extras.get(key).toString().isEmpty()) {
continue;
}
// For the special case of the supported HTML elements, which prints the same for the
// rootWebArea on each test, assert consistency and suppress from results.
if (key.equals(EXTRAS_KEY_SUPPORTED_ELEMENTS)) {
continue;
}
// To prevent flakiness or dependency on screensize/form factor, drop the "offscreen"
// Bundle extra, unless explicitly allowed for this instance.
if (!includeScreenSizeDependentAttributes && key.equals(EXTRAS_KEY_OFFSCREEN)) {
continue;
}
// The AccessibilityNodeInfoCompat class uses the extras for backwards compatibility,
// so exclude anything that contains the classname in the key.
if (key.contains("AccessibilityNodeInfoCompat")) {
continue;
}
// CSS Display is very noisy and currently unused, so we exclude it here because we
// don't have a way to filter it for certain tests.
if (key.equals(EXTRAS_KEY_CSS_DISPLAY)) {
continue;
}
// Simplify the key String before printing to make test outputs easier to read.
bundleStrings.add(
key.replace("AccessibilityNodeInfo.", "")
+ "=\""
+ extras.get(key).toString()
+ "\"");
}
builder.append(TextUtils.join(", ", bundleStrings)).append("]");
return builder.toString();
}
}