/* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.talkback; import android.content.Context; import android.os.Build; import android.os.Bundle; import android.os.Parcelable; import android.support.annotation.IntDef; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.os.BuildCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; import android.util.SparseArray; import android.view.accessibility.AccessibilityEvent; import android.widget.GridView; import android.widget.ListView; import com.android.talkback.speechrules.NodeSpeechRuleProcessor; import com.android.utils.AccessibilityNodeInfoUtils; import com.android.utils.NodeFilter; import com.android.utils.Role; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.HashSet; import java.util.Set; /** * Manages the contextual collection state when the user is navigating between elements or touch * exploring. This class implements a state machine for determining what transition feedback should * be provided for collections. */ public class CollectionState { /** The possible collection transitions that can occur when moving from node to node. */ @Retention(RetentionPolicy.SOURCE) @IntDef({NAVIGATE_NONE, NAVIGATE_ENTER, NAVIGATE_EXIT, NAVIGATE_INTERIOR}) public @interface CollectionTransition {} /** Bitmask used when we need to identify a row transition, column transition, or both. */ @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, value = {TYPE_NONE, TYPE_ROW, TYPE_COLUMN}) public @interface RowColumnTransition {} /** The possible heading types for a table heading. */ @Retention(RetentionPolicy.SOURCE) @IntDef({TYPE_NONE, TYPE_ROW, TYPE_COLUMN, TYPE_INDETERMINATE}) public @interface TableHeadingType {} /** Whether the collection is horizontal or vertical. A square collection is vertical. */ @Retention(RetentionPolicy.SOURCE) @IntDef({ALIGNMENT_VERTICAL, ALIGNMENT_HORIZONTAL}) public @interface CollectionAlignment {} /** Transition to a node outside any collection from a node also outside any collection. */ public static final int NAVIGATE_NONE = 0; /** Transition to a node inside a collection from a node that is not in that collection. */ public static final int NAVIGATE_ENTER = 1; /** Transition to a node outside any collection from a node that is within a collection. */ public static final int NAVIGATE_EXIT = 2; /** Transition between two nodes in the same collection. */ public static final int NAVIGATE_INTERIOR = 3; public static final int TYPE_NONE = 0; public static final int TYPE_ROW = 1 << 0; public static final int TYPE_COLUMN = 1 << 1; public static final int TYPE_INDETERMINATE = 1 << 2; public static final int ALIGNMENT_VERTICAL = 0; public static final int ALIGNMENT_HORIZONTAL = 1; static final String EVENT_ROW = "AccessibilityNodeInfo.CollectionItemInfo.rowIndex"; static final String EVENT_COLUMN = "AccessibilityNodeInfo.CollectionItemInfo.columnIndex"; static final String EVENT_HEADING = "AccessibilityNodeInfo.CollectionItemInfo.heading"; private static final String CLASS_LISTVIEW = ListView.class.getName(); private static final String CLASS_GRIDVIEW = GridView.class.getName(); private @CollectionTransition int mCollectionTransition = NAVIGATE_NONE; private @RowColumnTransition int mRowColumnTransition = TYPE_NONE; private AccessibilityNodeInfoCompat mCollectionRoot; private AccessibilityNodeInfoCompat mLastAnnouncedNode; private ItemState mItemState; private SparseArray<CharSequence> mRowHeaders = new SparseArray<>(); private SparseArray<CharSequence> mColumnHeaders = new SparseArray<>(); private int mCollectionLevel = -1; private boolean mShouldComputeHeaders = false; private boolean mShouldComputeNumbering = false; private static final NodeFilter FILTER_COLLECTION = new NodeFilter() { @Override public boolean accept(AccessibilityNodeInfoCompat node) { int role = Role.getRole(node); return role == Role.ROLE_LIST || role == Role.ROLE_GRID; } }; private static final NodeFilter FILTER_HIERARCHICAL_COLLECTION = new NodeFilter() { @Override public boolean accept(AccessibilityNodeInfoCompat node) { return FILTER_COLLECTION.accept(node) && node.getCollectionInfo() != null && node.getCollectionInfo().isHierarchical(); } }; private static final NodeFilter FILTER_FLAT_COLLECTION = new NodeFilter() { @Override public boolean accept(AccessibilityNodeInfoCompat node) { return FILTER_COLLECTION.accept(node) && (node.getCollectionInfo() == null || !node.getCollectionInfo().isHierarchical()); } }; private static final NodeFilter FILTER_COLLECTION_ITEM = new NodeFilter() { @Override public boolean accept(AccessibilityNodeInfoCompat node) { return node != null && node.getCollectionItemInfo() != null; } }; private static final NodeFilter FILTER_WEBVIEW = new NodeFilter() { @Override public boolean accept(AccessibilityNodeInfoCompat node) { return node != null && Role.getRole(node) == Role.ROLE_WEB_VIEW; } }; /** * Base interface for internal collection item state. It should be kept private because * clients of the CollectionState should not need polymorphism for ListItemState/TableItemState. * On the other hand, polymorphic behavior is quite useful for simplifying some of our internal * logic. */ private interface ItemState { /** * @return The row-column transition from the {@code from} state to the current state. * If the {@code from} state and the current state are of incompatible types, * should return {@code TYPE_ROW | TYPE_COLUMN}. */ public @RowColumnTransition int getTransition(@NonNull ItemState from); } public static class ListItemState implements ItemState { /** Whether the list item is a heading. */ private final boolean mHeading; /** The index of the list item. */ private final int mIndex; /** Whether the index should be displayed; used to work around a framework bug pre-N. */ private final boolean mDisplayIndex; public ListItemState(boolean heading, int index, boolean displayIndex) { mHeading = heading; mIndex = index; mDisplayIndex = displayIndex; } @Override public @RowColumnTransition int getTransition(@NonNull ItemState from) { if (!(from instanceof ListItemState)) { return TYPE_ROW | TYPE_COLUMN; } ListItemState otherListItemState = (ListItemState) from; if (mIndex != otherListItemState.mIndex) { return TYPE_ROW | TYPE_COLUMN; } return TYPE_NONE; } public boolean isHeading() { return mHeading; } public int getIndex() { if (mDisplayIndex) { return mIndex; } else { return -1; } } } public static class TableItemState implements ItemState { /** Indicates whether the table cell is a row, column, or indeterminate heading. */ private final @TableHeadingType int mHeading; /** The row name, or {@code null} if the row is unnamed. */ private final CharSequence mRowName; /** The column name, or {@code null} if the column is unnamed. */ private final CharSequence mColumnName; /** The row index. */ private final int mRowIndex; /** The column index. */ private final int mColumnIndex; /** Whether the indices should be displayed; used to work around a framework bug pre-N. */ private final boolean mDisplayIndices; public TableItemState(@TableHeadingType int heading, CharSequence rowName, CharSequence columnName, int rowIndex, int columnIndex, boolean displayIndices) { mHeading = heading; mRowName = rowName; mColumnName = columnName; mRowIndex = rowIndex; mColumnIndex = columnIndex; mDisplayIndices = displayIndices; } @Override public @RowColumnTransition int getTransition(@NonNull ItemState other) { if (!(other instanceof TableItemState)) { return TYPE_ROW | TYPE_COLUMN; } TableItemState otherTableItemState = (TableItemState) other; int transition = TYPE_NONE; if (mRowIndex != otherTableItemState.mRowIndex) { transition |= TYPE_ROW; } if (mColumnIndex != otherTableItemState.mColumnIndex) { transition |= TYPE_COLUMN; } return transition; } public @TableHeadingType int getHeadingType() { return mHeading; } public CharSequence getRowName() { return mRowName; } public CharSequence getColumnName() { return mColumnName; } public int getRowIndex() { if (mDisplayIndices) { return mRowIndex; } else { return -1; } } public int getColumnIndex() { if (mDisplayIndices) { return mColumnIndex; } else { return -1; } } } public CollectionState() {} public @CollectionTransition int getCollectionTransition() { return mCollectionTransition; } public @RowColumnTransition int getRowColumnTransition() { return mRowColumnTransition; } public @Nullable CharSequence getCollectionName() { return AccessibilityNodeInfoUtils.getNodeText(mCollectionRoot); } /** * @return Either {@link Role#ROLE_LIST} or {@link Role#ROLE_GRID} if there is a collection, * or {@link Role#ROLE_NONE} if there isn't one. * */ public @Role.RoleName int getCollectionRole() { return Role.getRole(mCollectionRoot); } public CharSequence getCollectionRoleDescription(Context context) { if (mCollectionRoot == null) { return null; } else { return Role.getRoleDescriptionOrDefault(context, mCollectionRoot); } } public int getCollectionRowCount() { if (mCollectionRoot == null || mCollectionRoot.getCollectionInfo() == null || !mShouldComputeNumbering) { return -1; } return mCollectionRoot.getCollectionInfo().getRowCount(); } public int getCollectionColumnCount() { if (mCollectionRoot == null || mCollectionRoot.getCollectionInfo() == null || !mShouldComputeNumbering) { return -1; } return mCollectionRoot.getCollectionInfo().getColumnCount(); } public @CollectionAlignment int getCollectionAlignment() { if (mCollectionRoot == null || !mShouldComputeNumbering) { return ALIGNMENT_VERTICAL; } else { return getCollectionAlignmentInternal(mCollectionRoot.getCollectionInfo()); } } public static @CollectionAlignment int getCollectionAlignmentInternal( @Nullable CollectionInfoCompat collection) { if (collection == null || collection.getRowCount() >= collection.getColumnCount()) { return ALIGNMENT_VERTICAL; } else { return ALIGNMENT_HORIZONTAL; } } public boolean doesCollectionExist() { if (mCollectionRoot == null) { return false; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { // If collection can be refresh successfully, it still exists. return mCollectionRoot.refresh(); } else { // Assume that the collection is still there since refresh() returns false for < API 18. return true; } } /** * Guaranteed to return a non-{@code null} ListItemState if {@link #getRowColumnTransition()} * is not {@link #TYPE_NONE} and {@link #getCollectionRole()} is {@link Role#ROLE_LIST}. */ @Nullable public ListItemState getListItemState() { if (mItemState != null && mItemState instanceof ListItemState) { return (ListItemState) mItemState; } return null; } @Nullable private static ListItemState getListItemStateInternal( AccessibilityNodeInfoCompat collectionRoot, AccessibilityNodeInfoCompat announcedNode, AccessibilityEvent event, boolean computeHeaders, boolean computeNumbering) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { return getListItemStateKitKat(collectionRoot, announcedNode, computeHeaders, computeNumbering); } else { return getListItemStateJellyBean(event); } } @Nullable private static ListItemState getListItemStateJellyBean(AccessibilityEvent event) { if (event == null) { return null; } Parcelable parcelable = event.getParcelableData(); if (parcelable instanceof Bundle) { Bundle bundle = (Bundle) parcelable; // There's no reliable way of determining whether a list is vertical or horizontal from // the events, so assume that it's a vertical list. if (bundle.containsKey(EVENT_ROW)) { int rowIndex = bundle.getInt(EVENT_ROW, -1); boolean heading = bundle.getBoolean(EVENT_HEADING, false); return new ListItemState(heading, rowIndex, true /* displayIndex */); } } return null; } @Nullable private static ListItemState getListItemStateKitKat( AccessibilityNodeInfoCompat collectionRoot, AccessibilityNodeInfoCompat announcedNode, boolean computeHeaders, boolean computeNumbering) { if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) { return null; } // Checking the ancestors should incur zero performance penalty in the typical case // where list items are direct descendants. Assuming list items are not deeply // nested, any performance penalty would be minimal. AccessibilityNodeInfoCompat collectionItem = AccessibilityNodeInfoUtils .getSelfOrMatchingAncestor(announcedNode, collectionRoot, FILTER_COLLECTION_ITEM); if (collectionItem == null) { return null; } CollectionInfoCompat collection = collectionRoot.getCollectionInfo(); CollectionItemInfoCompat item = collectionItem.getCollectionItemInfo(); boolean heading = computeHeaders && item.isHeading(); int index; if (getCollectionAlignmentInternal(collection) == ALIGNMENT_VERTICAL) { index = getRowIndex(item, collection); } else { index = getColumnIndex(item, collection); } collectionItem.recycle(); return new ListItemState(heading, index, computeNumbering); } /** * Guaranteed to return a non-{@code null} TableItemState if {@link #getRowColumnTransition()} * is not {@link #TYPE_NONE} and {@link #getCollectionRole()} is {@link Role#ROLE_GRID}. */ @Nullable public TableItemState getTableItemState() { if (mItemState != null && mItemState instanceof TableItemState) { return (TableItemState) mItemState; } return null; } @Nullable private static TableItemState getTableItemStateInternal( AccessibilityNodeInfoCompat collectionRoot, AccessibilityNodeInfoCompat announcedNode, AccessibilityEvent event, SparseArray<CharSequence> rowHeaders, SparseArray<CharSequence> columnHeaders, boolean computeHeaders, boolean computeNumbering) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { return getTableItemStateKitKat(collectionRoot, announcedNode, rowHeaders, columnHeaders, computeHeaders, computeNumbering); } else { return getTableItemStateJellyBean(event); } } @Nullable private static TableItemState getTableItemStateJellyBean(AccessibilityEvent event) { if (event == null) { return null; } Parcelable parcelable = event.getParcelableData(); if (parcelable instanceof Bundle) { Bundle bundle = (Bundle) parcelable; if (bundle.containsKey(EVENT_ROW) || bundle.containsKey(EVENT_COLUMN)) { int rowIndex = bundle.getInt(EVENT_ROW, -1); int columnIndex = bundle.getInt(EVENT_COLUMN, -1); boolean heading = bundle.getBoolean(EVENT_HEADING, false); return new TableItemState(heading ? TYPE_INDETERMINATE : TYPE_NONE, null /* rowName */, null /* columnName */, rowIndex, columnIndex, true /* displayIndices */); } } return null; } @Nullable private static TableItemState getTableItemStateKitKat( AccessibilityNodeInfoCompat collectionRoot, AccessibilityNodeInfoCompat announcedNode, SparseArray<CharSequence> rowHeaders, SparseArray<CharSequence> columnHeaders, boolean computeHeaders, boolean computeNumbering) { if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) { return null; } // Checking the ancestors should incur zero performance penalty in the typical case // where list items are direct descendants. Assuming list items are not deeply // nested, any performance penalty would be minimal. AccessibilityNodeInfoCompat collectionItem = AccessibilityNodeInfoUtils .getSelfOrMatchingAncestor(announcedNode, collectionRoot, FILTER_COLLECTION_ITEM); if (collectionItem == null) { return null; } CollectionInfoCompat collection = collectionRoot.getCollectionInfo(); CollectionItemInfoCompat item = collectionItem.getCollectionItemInfo(); int heading = computeHeaders ? getTableHeading(item, collection) : TYPE_NONE; int rowIndex = getRowIndex(item, collection); int columnIndex = getColumnIndex(item, collection); CharSequence rowName = rowIndex != -1 ? rowHeaders.get(rowIndex) : null; CharSequence columnName = columnIndex != -1 ? columnHeaders.get(columnIndex) : null; collectionItem.recycle(); return new TableItemState( heading, rowName, columnName, rowIndex, columnIndex, computeNumbering); } /** * If the collection is part of a hierarchy of collections (e.g. a tree or outlined list), * returns the nesting level of the collection, with 0 being the outermost list, 1 being the * list nested within the outermost list, and so forth. * If the collection is not part of a hierarchy, returns -1. */ public int getCollectionLevel() { return mCollectionLevel; } private static int getCollectionLevelInternal(@Nullable AccessibilityNodeInfoCompat node) { if (node == null) { return -1; } if (!FILTER_HIERARCHICAL_COLLECTION.accept(node)) { return -1; } return AccessibilityNodeInfoUtils.countMatchingAncestors(node, FILTER_HIERARCHICAL_COLLECTION); } /** * This method updates the collection state based on the new item focused by the user. * Internally, it advances the state machine and then acts on the new state, */ public void updateCollectionInformation(AccessibilityNodeInfoCompat announcedNode, AccessibilityEvent event) { if (announcedNode == null) { return; } AccessibilityNodeInfoCompat announcedNodeParent = announcedNode.getParent(); AccessibilityNodeInfoCompat newCollectionRoot = AccessibilityNodeInfoUtils .getSelfOrMatchingAncestor(announcedNodeParent, FILTER_COLLECTION); if (announcedNodeParent != null) { announcedNodeParent.recycle(); } // STATE DIAGRAM: // (None*)--------->(Enter*)--------->(Interior*)--------->(Exit) // ^ ^ | ^ | | // | | +---------------------------------> + | | // | + <--------------------------------------+ | // + <--------------------------------------------------------+ // * = can self loop. // Perform the state transition. switch (mCollectionTransition) { case NAVIGATE_ENTER: case NAVIGATE_INTERIOR: if (newCollectionRoot != null && newCollectionRoot.equals(mCollectionRoot)) { mCollectionTransition = NAVIGATE_INTERIOR; } else if (newCollectionRoot != null && shouldEnter(newCollectionRoot)) { mCollectionTransition = NAVIGATE_ENTER; } else { mCollectionTransition = NAVIGATE_EXIT; } break; case NAVIGATE_EXIT: case NAVIGATE_NONE: default: if (newCollectionRoot != null && shouldEnter(newCollectionRoot)) { mCollectionTransition = NAVIGATE_ENTER; } else { mCollectionTransition = NAVIGATE_NONE; } break; } // Act on the new state. switch (mCollectionTransition) { case NAVIGATE_ENTER: { // Only recompute workarounds once per collection. mShouldComputeHeaders = shouldComputeHeaders(newCollectionRoot); mShouldComputeNumbering = shouldComputeNumbering(newCollectionRoot); mCollectionLevel = getCollectionLevelInternal(newCollectionRoot); ItemState newItemState; if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { // Cache the row and column headers. updateTableHeaderInfo(newCollectionRoot, mRowHeaders, mColumnHeaders, mShouldComputeHeaders); newItemState = getTableItemStateInternal(newCollectionRoot, announcedNode, event, mRowHeaders, mColumnHeaders, mShouldComputeHeaders, mShouldComputeNumbering); } else { newItemState = getListItemStateInternal(newCollectionRoot, announcedNode, event, mShouldComputeHeaders, mShouldComputeNumbering); } // Row and column change only if we enter and there is collection item information. if (newItemState == null) { mRowColumnTransition = TYPE_NONE; } else { mRowColumnTransition = TYPE_ROW | TYPE_COLUMN; } AccessibilityNodeInfoUtils.recycleNodes(mCollectionRoot, mLastAnnouncedNode); mCollectionRoot = newCollectionRoot; mLastAnnouncedNode = AccessibilityNodeInfoCompat.obtain(announcedNode); mItemState = newItemState; break; } case NAVIGATE_INTERIOR: { ItemState newItemState; if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { newItemState = getTableItemStateInternal(newCollectionRoot, announcedNode, event, mRowHeaders, mColumnHeaders, mShouldComputeHeaders, mShouldComputeNumbering); } else { newItemState = getListItemStateInternal(newCollectionRoot, announcedNode, event, mShouldComputeHeaders, mShouldComputeNumbering); } // Determine if the row and/or column has changed. if (newItemState == null) { mRowColumnTransition = TYPE_NONE; } else if (mItemState == null || mLastAnnouncedNode == null || mLastAnnouncedNode.equals(announcedNode)) { // We want to repeat row/column feedback on refocus *of the exact same node*. mRowColumnTransition = TYPE_ROW | TYPE_COLUMN; } else { mRowColumnTransition = newItemState.getTransition(mItemState); } AccessibilityNodeInfoUtils.recycleNodes(mCollectionRoot, mLastAnnouncedNode); mCollectionRoot = newCollectionRoot; mLastAnnouncedNode = AccessibilityNodeInfoCompat.obtain(announcedNode); mItemState = newItemState; break; } case NAVIGATE_EXIT: { // We can clear the item state, but we need to keep the collection root. AccessibilityNodeInfoUtils.recycleNodes(mLastAnnouncedNode, newCollectionRoot); mRowColumnTransition = 0; mLastAnnouncedNode = null; mItemState = null; break; } case NAVIGATE_NONE: default: { // Safe to clear everything. AccessibilityNodeInfoUtils.recycleNodes(mCollectionRoot, mLastAnnouncedNode, newCollectionRoot); mRowColumnTransition = 0; mCollectionRoot = null; mLastAnnouncedNode = null; mItemState = null; break; } } } private static void updateTableHeaderInfo( AccessibilityNodeInfoCompat collectionRoot, SparseArray<CharSequence> rowHeaders, SparseArray<CharSequence> columnHeaders, boolean computeHeaders) { rowHeaders.clear(); columnHeaders.clear(); if (!computeHeaders) { return; } if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) { return; } // Limit search to children and grandchildren of the root node for performance reasons. // We want to search grandchildren because web pages put table headers <th> inside table // rows <tr> so they are nested two levels down. CollectionInfoCompat collectionInfo = collectionRoot.getCollectionInfo(); int numChildren = collectionRoot.getChildCount(); for (int i = 0; i < numChildren; ++i) { AccessibilityNodeInfoCompat child = collectionRoot.getChild(i); if (!updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders)) { int numGrandchildren = child.getChildCount(); for (int j = 0; j < numGrandchildren; ++j) { AccessibilityNodeInfoCompat grandchild = child.getChild(j); updateSingleTableHeader(grandchild, collectionInfo, rowHeaders, columnHeaders); grandchild.recycle(); } } child.recycle(); } } private static boolean updateSingleTableHeader(@Nullable AccessibilityNodeInfoCompat node, CollectionInfoCompat collectionInfo, SparseArray<CharSequence> rowHeaders, SparseArray<CharSequence> columnHeaders) { if (node == null) { return false; } CharSequence headingName = getHeaderText(node); CollectionItemInfoCompat itemInfo = node.getCollectionItemInfo(); if (itemInfo != null && headingName != null) { @RowColumnTransition int headingType = getTableHeading(itemInfo, collectionInfo); if ((headingType & TYPE_ROW) != 0) { rowHeaders.put(itemInfo.getRowIndex(), headingName); } if ((headingType & TYPE_COLUMN) != 0) { columnHeaders.put(itemInfo.getColumnIndex(), headingName); } return headingType != TYPE_NONE; } return false; } /** * For finding the name of the header, we want to use a simpler strategy than the * NodeSpeechRuleProcessor. We don't want to include the role description of items within the * header, because it will add confusion when the header name is appended to collection items. * But we do want to search down the tree in case the immediate root element doesn't have text. * * We traverse single children of single children until we find a node with text. If we hit any * node that has multiple children, we simply stop the search and return {@code null}. */ static CharSequence getHeaderText(AccessibilityNodeInfoCompat node) { if (node == null) { return null; } Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>(); try { AccessibilityNodeInfoCompat currentNode = AccessibilityNodeInfoCompat.obtain(node); while (currentNode != null) { if (!visitedNodes.add(currentNode)) { // Cycle in traversal. currentNode.recycle(); return null; } CharSequence nodeText = AccessibilityNodeInfoUtils.getNodeText(currentNode); if (nodeText != null) { return nodeText; } if (currentNode.getChildCount() != 1) { return null; } currentNode = currentNode.getChild(0); } } finally { AccessibilityNodeInfoUtils.recycleNodes(visitedNodes); } return null; } /** * In this method, only one cell per row and per column can be the row or column header. * Additionally, a cell can be a row or column header but not both. * * @return {@code TYPE_ROW} or {@ocde TYPE_COLUMN} for row or column headers; * {@code TYPE_INDETERMINATE} for cells marked as headers that are neither row nor column * headers; {@code TYPE_NONE} for all other cells. */ private static @TableHeadingType int getTableHeading(@NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { if (item.isHeading()) { if (item.getRowSpan() == 1 && item.getColumnSpan() == 1) { if (getRowIndex(item, collection) == 0) { return TYPE_COLUMN; } if (getColumnIndex(item, collection) == 0) { return TYPE_ROW; } } return TYPE_INDETERMINATE; } return TYPE_NONE; } /** * @return -1 if there is no valid row index for the item; otherwise the item's row index */ private static int getRowIndex(@NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { if (item.getRowSpan() == collection.getRowCount()) { return -1; } else if (item.getRowIndex() < 0) { return -1; } else { return item.getRowIndex(); } } /** * @return -1 if there is no valid column index for the item; otherwise the item's column index */ private static int getColumnIndex(@NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { if (item.getColumnSpan() == collection.getColumnCount()) { return -1; } else if (item.getColumnIndex() < 0) { return -1; } else { return item.getColumnIndex(); } } private static boolean shouldEnter(@NonNull AccessibilityNodeInfoCompat collectionRoot) { if (collectionRoot.getCollectionInfo() != null) { // If the collection info reports that this is a 1x1 collection, then we discard it // and treat it as though we are outside of a collection. CollectionInfoCompat collectionInfo = collectionRoot.getCollectionInfo(); if (collectionInfo.getColumnCount() <= 1 && collectionInfo.getRowCount() <= 1) { return false; } } else if (collectionRoot.getChildCount() <= 1) { // If we don't have collection info, use the child count as an approximation. return false; } // If the collection is flat and contains other flat collections, then we discard it. // We only announce hierarchies of collections if they are explicitly marked hierarchical. // Otherwise we announce only the innermost collection. if (FILTER_FLAT_COLLECTION.accept(collectionRoot) && AccessibilityNodeInfoUtils .hasMatchingDescendant(collectionRoot, FILTER_FLAT_COLLECTION)) { return false; } return true; } /** * Don't compute headers if: * (1) API level is pre-N, and * (2) the collection root is not a descendant of a WebView, and * (3) the collection root is itself a ListView or GridView. * * Under these circumstances, the framework ListView/GridView will mark headers as non-headers * and vice-versa. */ private static boolean shouldComputeHeaders( @NonNull AccessibilityNodeInfoCompat collectionRoot) { if (!BuildCompat.isAtLeastN()) { if (!AccessibilityNodeInfoUtils.hasMatchingAncestor(collectionRoot, FILTER_WEBVIEW)) { // Bugs exist in specific classes, so check class names and not roles. if (nodeMatchesAnyClassName(collectionRoot, CLASS_LISTVIEW, CLASS_GRIDVIEW)) { return false; } } } return true; } /** * Don't compute indices or row/column counts if: * (1) API level is pre-N, and * (2) the collection root is not a descendant of a WebView. * * Item indices are broken in some major first-party apps that use "spacer" items in * collections; this check makes sure no apps in the wild are affected. * TODO: Re-evaluate this check before N release to see if it needs to be extended to N. */ private static boolean shouldComputeNumbering( @NonNull AccessibilityNodeInfoCompat collectionRoot) { if (!BuildCompat.isAtLeastN()) { if (!AccessibilityNodeInfoUtils.hasMatchingAncestor(collectionRoot, FILTER_WEBVIEW)) { return false; } } return true; } private static boolean nodeMatchesAnyClassName(@Nullable AccessibilityNodeInfoCompat node, CharSequence... classNames) { if (node == null || node.getClassName() == null || classNames == null) { return false; } for (CharSequence name : classNames) { if (node.getClassName().equals(name)) { return true; } } return false; } }