From 5bc2b7be747d11d22407bade903a4be10248113f Mon Sep 17 00:00:00 2001 From: nab5 Date: Thu, 8 Oct 2026 05:23:50 +0530 Subject: [PATCH 1/4] Add configurable table header reading and row/column coordinate announcements --- .../talkback/TalkBackService.java | 18 ++ .../CollectionStateFeedbackUtils.java | 284 ++++++++++++++++++ .../talkback/compositor/GlobalVariables.java | 83 +++++ ...eViewAccessibilityFocusedFeedbackRule.java | 74 +++-- ...AccessibilityFocusedFeedbackRuleForTV.java | 63 ++-- .../src/main/res/values/donottranslate.xml | 20 ++ talkback/src/main/res/values/strings.xml | 9 + .../main/res/xml/verbosity_preferences.xml | 20 ++ .../res/xml-watch/verbosity_preferences.xml | 20 ++ .../utils/AccessibilityNodeInfoUtils.java | 26 +- .../utils/monitor/CollectionState.java | 152 +++++++--- 11 files changed, 674 insertions(+), 95 deletions(-) diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java b/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java index 3e966ba36..b3f995e90 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java @@ -3393,6 +3393,24 @@ protected void reloadPreferences(@Nullable String key) { res.getBoolean(R.bool.pref_speak_container_element_positions_default)); globalVariables.setSpeakCollectionInfo(speakCollectionInfo); + // Update preference: table column headers order/visibility. + String tableColumnHeaders = + SharedPreferencesUtils.getStringPref( + prefs, + res, + R.string.pref_table_column_headers_key, + R.string.pref_table_column_headers_default); + globalVariables.setTableColumnHeaders(tableColumnHeaders); + + // Update preference: speak table row and column numbers. + boolean speakTableRowColNumbers = + VerbosityPreferences.getPreferenceValueBool( + prefs, + res, + res.getString(R.string.pref_table_speak_row_column_numbers_key), + res.getBoolean(R.bool.pref_table_speak_row_column_numbers_default)); + globalVariables.setSpeakTableRowColumnNumbers(speakTableRowColNumbers); + // Update preference: speak roles. boolean speakRoles = VerbosityPreferences.getPreferenceValueBool( diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/CollectionStateFeedbackUtils.java b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/CollectionStateFeedbackUtils.java index 0ecb09425..9c66218d2 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/CollectionStateFeedbackUtils.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/CollectionStateFeedbackUtils.java @@ -27,8 +27,10 @@ import android.content.Context; import android.text.TextUtils; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; import com.google.android.accessibility.talkback.R; import com.google.android.accessibility.utils.AccessibilityNodeInfoUtils; +import com.google.android.accessibility.utils.Filter; import com.google.android.accessibility.utils.Role; import com.google.android.accessibility.utils.monitor.CollectionState; import com.google.android.libraries.accessibility.utils.log.LogUtils; @@ -287,6 +289,288 @@ public static CharSequence getCollectionItemTransitionDescription( return getCollectionTableItemColumnName(collectionState, tableItemColumnIndex, context); } + /** + * Formats feedback for a table item cell based on user verbosity preferences. + * + * @param focusedNode The focused node in the table cell + * @param cellContent The primary text content of the cell + * @param collectionState The current collection state + * @param context Application context + * @param columnHeadersMode One of TABLE_HEADERS_BEFORE, TABLE_HEADERS_AFTER, or TABLE_HEADERS_OFF + * @param speakTableRowColumnNumbers Whether row and column coordinates are spoken + * @param speakRoles Whether role descriptions are spoken + * @return The formatted feedback text with pauses where appropriate + */ + public static @Nullable AccessibilityNodeInfoCompat findCellAt( + @Nullable AccessibilityNodeInfoCompat tableRoot, int targetRow, int targetCol) { + if (tableRoot == null || targetRow < 0 || targetCol < 0) { + return null; + } + return AccessibilityNodeInfoUtils.getMatchingDescendant( + tableRoot, + Filter.node( + (node) -> { + if (node == null) { + return false; + } + CollectionItemInfoCompat itemInfo = node.getCollectionItemInfo(); + return itemInfo != null + && itemInfo.getRowIndex() == targetRow + && itemInfo.getColumnIndex() == targetCol; + })); + } + + /** + * Formats feedback for a table item cell based on user verbosity preferences. + * + * @param focusedNode The focused node in the table cell + * @param cellNode The table cell node (or null) + * @param tableRoot The table root node (or null) + * @param cellContent The primary text content of the cell + * @param collectionState The current collection state + * @param context Application context + * @param columnHeadersMode One of TABLE_HEADERS_BEFORE, TABLE_HEADERS_AFTER, or TABLE_HEADERS_OFF + * @param speakTableRowColumnNumbers Whether row and column coordinates are spoken + * @param speakRoles Whether role descriptions are spoken + * @param isRowTransition Whether this focus move crossed a row boundary + * @return The formatted feedback text with pauses where appropriate + */ + public static CharSequence getTableItemCellFeedback( + @Nullable AccessibilityNodeInfoCompat focusedNode, + @Nullable AccessibilityNodeInfoCompat cellNode, + @Nullable AccessibilityNodeInfoCompat tableRoot, + CharSequence cellContent, + CollectionState collectionState, + Context context, + String columnHeadersMode, + boolean speakTableRowColumnNumbers, + boolean speakRoles, + boolean isRowTransition) { + CollectionState.TableItemState itemState = + (collectionState != null) ? collectionState.getTableItemState() : null; + + if (cellNode == null && itemState == null) { + return cellContent; + } + + int rowIndex = (itemState != null) ? itemState.getRowIndex() : -1; + int colIndex = (itemState != null) ? itemState.getColumnIndex() : -1; + if (cellNode != null && cellNode.getCollectionItemInfo() != null) { + if (rowIndex < 0) { + rowIndex = cellNode.getCollectionItemInfo().getRowIndex(); + } + if (colIndex < 0) { + colIndex = cellNode.getCollectionItemInfo().getColumnIndex(); + } + } + + int headingType = + (collectionState != null) + ? getCollectionTableItemHeadingType(collectionState) + : CollectionState.TYPE_NONE; + boolean isHeading = + (headingType == CollectionState.TYPE_COLUMN) + || (rowIndex == 0 + && (headingType != CollectionState.TYPE_ROW) + && ((focusedNode != null && AccessibilityNodeInfoUtils.isHeading(focusedNode)) + || (cellNode != null + && cellNode.getCollectionItemInfo() != null + && cellNode.getCollectionItemInfo().isHeading()) + || (cellNode != null && AccessibilityNodeInfoUtils.isHeading(cellNode)) + || (tableRoot != null + && tableRoot.getCollectionInfo() != null + && (tableRoot.getCollectionInfo().getRowCount() > 1 + || tableRoot.getCollectionInfo().getRowCount() == -1)))); + + // If this cell is a column heading itself (e.g. in row 0): + if (isHeading) { + List joinList = new ArrayList<>(); + if (speakTableRowColumnNumbers) { + if (isRowTransition && rowIndex >= 0) { + joinList.add(context.getString(R.string.row_index_template, rowIndex + 1)); + } + if (colIndex >= 0) { + joinList.add(context.getString(R.string.column_index_template, colIndex + 1)); + } + } + CharSequence text = + !TextUtils.isEmpty(cellContent) + ? cellContent + : ((cellNode != null) ? AccessibilityNodeInfoUtils.getNodeText(cellNode) : ""); + if (!TextUtils.isEmpty(text)) { + joinList.add(text); + } + if (speakRoles + && (collectionState == null + || TextUtils.isEmpty(getCollectionTableItemRoleDescription(collectionState)))) { + joinList.add(context.getString(R.string.column_heading_template)); + } + return CompositorUtils.joinCharSequences(joinList, CompositorUtils.getSeparator(), true); + } + + // Otherwise, this is a table data cell (or row header cell). + CharSequence colHeader = null; + if (!GlobalVariables.TABLE_HEADERS_OFF.equals(columnHeadersMode)) { + if (itemState != null) { + colHeader = itemState.getColumnName(); + } + if (TextUtils.isEmpty(colHeader) + && collectionState != null + && colIndex >= 0 + && collectionState.getColumnHeaders() != null) { + colHeader = collectionState.getColumnHeaders().get(colIndex); + } + if (TextUtils.isEmpty(colHeader) && cellNode != null) { + colHeader = AccessibilityNodeInfoUtils.getGridColumnTitle(cellNode); + } + if (TextUtils.isEmpty(colHeader) && tableRoot != null && colIndex >= 0) { + AccessibilityNodeInfoCompat headerNode = findCellAt(tableRoot, 0, colIndex); + if (headerNode != null) { + colHeader = CollectionState.getHeaderText(headerNode); + if (TextUtils.isEmpty(colHeader)) { + colHeader = AccessibilityNodeInfoUtils.getNodeText(headerNode); + } + if (!TextUtils.isEmpty(colHeader) + && collectionState != null + && collectionState.getColumnHeaders() != null) { + collectionState.getColumnHeaders().put(colIndex, colHeader); + } + } + } + } + + CharSequence colCoord = + (speakTableRowColumnNumbers && colIndex >= 0) + ? context.getString(R.string.column_index_template, colIndex + 1) + : null; + CharSequence rowCoord = + (speakTableRowColumnNumbers && rowIndex >= 0 && isRowTransition) + ? context.getString(R.string.row_index_template, rowIndex + 1) + : null; + CharSequence rowHeader = + (isRowTransition + && headingType != CollectionState.TYPE_ROW + && itemState != null) + ? itemState.getRowName() + : null; + + CharSequence cellText = (cellContent == null) ? "" : cellContent.toString().trim(); + if (TextUtils.isEmpty(cellText) && cellNode != null) { + CharSequence nodeText = AccessibilityNodeInfoUtils.getNodeText(cellNode); + if (nodeText != null) { + cellText = nodeText.toString().trim(); + } + } + + if (GlobalVariables.TABLE_HEADERS_BEFORE.equals(columnHeadersMode)) { + // Header before cell data + List prefixList = new ArrayList<>(); + if (rowCoord != null) { + prefixList.add(rowCoord); + } + if (rowHeader != null && rowCoord == null) { + prefixList.add(rowHeader); + } + if (!TextUtils.isEmpty(colHeader)) { + prefixList.add(colHeader); + } + if (colCoord != null) { + prefixList.add(colCoord); + } + + CharSequence prefix = + CompositorUtils.joinCharSequences(prefixList, CompositorUtils.getSeparator(), true); + if (TextUtils.isEmpty(prefix)) { + return cellText; + } + if (TextUtils.isEmpty(cellText)) { + return prefix; + } + return ensureTerminalPunctuation(prefix) + " " + cellText; + } else if (GlobalVariables.TABLE_HEADERS_OFF.equals(columnHeadersMode)) { + List mainList = new ArrayList<>(); + if (rowCoord != null) { + mainList.add(rowCoord); + } + if (rowHeader != null && rowCoord == null) { + mainList.add(rowHeader); + } + if (colCoord != null) { + mainList.add(colCoord); + } + if (!TextUtils.isEmpty(cellText)) { + mainList.add(cellText); + } + return CompositorUtils.joinCharSequences(mainList, CompositorUtils.getSeparator(), true); + } else { + // Header after cell data (default: TABLE_HEADERS_AFTER) + List mainList = new ArrayList<>(); + if (rowCoord != null) { + mainList.add(rowCoord); + } + if (rowHeader != null && rowCoord == null) { + mainList.add(rowHeader); + } + if (colCoord != null) { + mainList.add(colCoord); + } + if (!TextUtils.isEmpty(cellText)) { + mainList.add(cellText); + } + + CharSequence mainPart = + CompositorUtils.joinCharSequences(mainList, CompositorUtils.getSeparator(), true); + if (TextUtils.isEmpty(colHeader)) { + return mainPart; + } + if (TextUtils.isEmpty(mainPart)) { + return colHeader; + } + + // Add pause between cell data and column header via terminal punctuation + return ensureTerminalPunctuation(mainPart) + " " + colHeader; + } + } + + public static CharSequence getTableItemCellFeedback( + @Nullable AccessibilityNodeInfoCompat focusedNode, + CharSequence cellContent, + CollectionState collectionState, + Context context, + String columnHeadersMode, + boolean speakTableRowColumnNumbers, + boolean speakRoles) { + AccessibilityNodeInfoCompat cellNode = + (focusedNode != null) ? AccessibilityNodeInfoUtils.getTableCellUnderTable(focusedNode) : null; + AccessibilityNodeInfoCompat tableRoot = + (cellNode != null) ? AccessibilityNodeInfoUtils.getTableRoot(cellNode) + : ((focusedNode != null) ? AccessibilityNodeInfoUtils.getTableRoot(focusedNode) : null); + boolean isRowTransition = + (collectionState != null) && getCollectionIsRowTransition(collectionState); + return getTableItemCellFeedback( + focusedNode, + cellNode, + tableRoot, + cellContent, + collectionState, + context, + columnHeadersMode, + speakTableRowColumnNumbers, + speakRoles, + isRowTransition); + } + + private static String ensureTerminalPunctuation(CharSequence text) { + if (TextUtils.isEmpty(text)) { + return ""; + } + String trimmed = text.toString().trim(); + if (trimmed.endsWith(".") || trimmed.endsWith("!") || trimmed.endsWith("?") || trimmed.endsWith(";")) { + return trimmed; + } + return trimmed + "."; + } + private static CharSequence getCollectionListItemPositionDescription( CollectionState collectionState, Context context) { int rowCount = collectionState.getCollectionRowCount(); diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java index fa5e5121d..5ecc202cb 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java @@ -51,6 +51,7 @@ import com.google.android.accessibility.utils.FormFactorUtils; import com.google.android.accessibility.utils.KeyboardUtils; import com.google.android.accessibility.utils.Logger; +import com.google.android.accessibility.utils.Role; import com.google.android.accessibility.utils.TimedFlags; import com.google.android.accessibility.utils.input.WindowsDelegate; import com.google.android.accessibility.utils.monitor.CollectionState; @@ -179,6 +180,13 @@ public class GlobalVariables extends TimedFlags implements ParseTree.VariableDel private boolean speakRoles = true; private boolean speakCollectionInfo = true; + public static final String TABLE_HEADERS_BEFORE = "before"; + public static final String TABLE_HEADERS_AFTER = "after"; + public static final String TABLE_HEADERS_OFF = "off"; + + private String tableColumnHeaders = TABLE_HEADERS_AFTER; + private boolean speakTableRowColumnNumbers = true; + // Control sounds: whether they are heard, and the ones heard or felt for focused controls. private boolean controlSoundsOn = false; private Set controlSounds = Collections.emptySet(); @@ -610,6 +618,22 @@ public void setSpeakCollectionInfo(boolean value) { speakCollectionInfo = value; } + public String getTableColumnHeaders() { + return tableColumnHeaders; + } + + public void setTableColumnHeaders(String value) { + tableColumnHeaders = value; + } + + public boolean getSpeakTableRowColumnNumbers() { + return speakTableRowColumnNumbers; + } + + public void setSpeakTableRowColumnNumbers(boolean value) { + speakTableRowColumnNumbers = value; + } + public boolean getSpeakRoles() { return speakRoles; } @@ -904,6 +928,65 @@ public CharSequence getCollectionItemTransitionDescription( focusedNode, collectionState, mContext); } + public CollectionState getCollectionState() { + return collectionState; + } + + private int lastTableItemRowIndex = -1; + private int lastTableItemColIndex = -1; + private @Nullable AccessibilityNodeInfoCompat lastTableRoot = null; + + public boolean isFocusedNodeInTable(@Nullable AccessibilityNodeInfoCompat node) { + if (collectionState.getCollectionRole() == Role.ROLE_GRID + && collectionState.getTableItemState() != null) { + return true; + } + return node != null && AccessibilityNodeInfoUtils.getTableCellUnderTable(node) != null; + } + + public boolean isFocusedNodeInTable() { + return isFocusedNodeInTable(null); + } + + public CharSequence getTableItemCellFeedback( + @Nullable AccessibilityNodeInfoCompat focusedNode, CharSequence cellContent) { + AccessibilityNodeInfoCompat cellNode = + (focusedNode != null) ? AccessibilityNodeInfoUtils.getTableCellUnderTable(focusedNode) : null; + AccessibilityNodeInfoCompat tableRoot = + (cellNode != null) ? AccessibilityNodeInfoUtils.getTableRoot(cellNode) + : ((focusedNode != null) ? AccessibilityNodeInfoUtils.getTableRoot(focusedNode) : null); + + int curRow = -1; + int curCol = -1; + if (cellNode != null && cellNode.getCollectionItemInfo() != null) { + curRow = cellNode.getCollectionItemInfo().getRowIndex(); + curCol = cellNode.getCollectionItemInfo().getColumnIndex(); + } else if (collectionState.getTableItemState() != null) { + curRow = collectionState.getTableItemState().getRowIndex(); + curCol = collectionState.getTableItemState().getColumnIndex(); + } + + boolean isRowTransition = (collectionState.getRowColumnTransition() & CollectionState.TYPE_ROW) != 0; + if (!isRowTransition && curRow >= 0) { + isRowTransition = (lastTableRoot == null || !lastTableRoot.equals(tableRoot) || curRow != lastTableItemRowIndex); + } + lastTableItemRowIndex = curRow; + lastTableItemColIndex = curCol; + lastTableRoot = tableRoot; + + return CollectionStateFeedbackUtils.getTableItemCellFeedback( + focusedNode, + cellNode, + tableRoot, + cellContent, + collectionState, + mContext, + tableColumnHeaders, + speakTableRowColumnNumbers, + speakRoles, + isRowTransition); + } + /** Returns if the reading menu has actions settings. */ public boolean hasReadingMenuActionSettings() { return selectorController != null && selectorController.isSettingAvailable(Setting.ACTIONS); diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java index 2833dae23..a9d319fc1 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java @@ -258,10 +258,11 @@ private static CharSequence viewAccessibilityFocusedDescription( CharSequence eventDescription = AccessibilityEventFeedbackUtils.getEventContentDescriptionOrEventAggregateText( event, preferredLocale); + CharSequence contentDescription; if (!TextUtils.isEmpty(nodeUnlabelledState)) { CharSequence unlabelledDescription = TextUtils.isEmpty(eventDescription) ? nodeUnlabelledState : eventDescription; - outputJoinList.add(unlabelledDescription); + contentDescription = unlabelledDescription; logString .append(String.format("\n unlabelledDescription={%s}", unlabelledDescription)) .append(String.format(", eventDescription={%s}", eventDescription)); @@ -269,14 +270,31 @@ private static CharSequence viewAccessibilityFocusedDescription( CharSequence nodeTreeDescription = treeNodesDescription.aggregateNodeTreeDescription(node, event); if (!TextUtils.isEmpty(nodeTreeDescription)) { - outputJoinList.add(nodeTreeDescription); + contentDescription = nodeTreeDescription; logString.append(String.format("\n nodeTreeDescription={%s}", nodeTreeDescription)); } else { - outputJoinList.add(eventDescription); + contentDescription = eventDescription; logString.append(String.format("\n eventDescription={%s}", eventDescription)); } } + boolean speakCollectionInfo = globalVariables.getSpeakCollectionInfo(); + boolean speakRoles = globalVariables.getSpeakRoles(); + boolean isTableItem = globalVariables.isFocusedNodeInTable(node); + + if (isTableItem) { + CharSequence tableCellFeedback = + globalVariables.getTableItemCellFeedback(node, contentDescription); + if (!TextUtils.isEmpty(tableCellFeedback)) { + outputJoinList.add(tableCellFeedback); + logString.append(String.format("\n tableCellFeedback={%s}", tableCellFeedback)); + } + } else { + if (!TextUtils.isEmpty(contentDescription)) { + outputJoinList.add(contentDescription); + } + } + // Add phonetic spelling if necessary. Optional phoneticExample = processorPhoneticLetters.getPhoneticLetterForKeyboardFocusEvent(event); @@ -284,31 +302,31 @@ private static CharSequence viewAccessibilityFocusedDescription( logString.append(String.format("\n phoneticExample={%s}", phoneticExample)); // Prepare Collection item transition state or Node role/heading description for feedback. - boolean speakCollectionInfo = globalVariables.getSpeakCollectionInfo(); - boolean speakRoles = globalVariables.getSpeakRoles(); - logString - .append(String.format("\n Verbosity speakCollectionInfo=%s", speakCollectionInfo)) - .append(String.format(", speakRoles=%s", speakRoles)); - CharSequence collectionItemTransition = - speakCollectionInfo ? globalVariables.getCollectionItemTransitionDescription(node) : ""; - if (!TextUtils.isEmpty(collectionItemTransition)) { - outputJoinList.add(collectionItemTransition); - logString.append( - String.format("\n collectionItemTransition={%s}", collectionItemTransition)); - } else if (speakRoles - && !WebInterfaceUtils.isWebContainer(node) - && AccessibilityNodeInfoUtils.isHeading(node)) { - // If the source node has collection item transition, collectionItemTransition text would - // not be empty. And TalkBack should announce the collection item transition information or it - // should fallback to announce the role/heading description. - CharSequence nodeRoleDescription = - AccessibilityNodeFeedbackUtils.getNodeRoleDescription(node, context, globalVariables); - if (!TextUtils.isEmpty(nodeRoleDescription)) { - outputJoinList.add(nodeRoleDescription); - logString.append(String.format("\n nodeRoleDescription={%s}", nodeRoleDescription)); - } else { - outputJoinList.add(context.getString(R.string.heading_template)); - logString.append("\n heading"); + if (!isTableItem) { + logString + .append(String.format("\n Verbosity speakCollectionInfo=%s", speakCollectionInfo)) + .append(String.format(", speakRoles=%s", speakRoles)); + CharSequence collectionItemTransition = + speakCollectionInfo ? globalVariables.getCollectionItemTransitionDescription(node) : ""; + if (!TextUtils.isEmpty(collectionItemTransition)) { + outputJoinList.add(collectionItemTransition); + logString.append( + String.format("\n collectionItemTransition={%s}", collectionItemTransition)); + } else if (speakRoles + && !WebInterfaceUtils.isWebContainer(node) + && AccessibilityNodeInfoUtils.isHeading(node)) { + // If the source node has collection item transition, collectionItemTransition text would + // not be empty. And TalkBack should announce the collection item transition information or it + // should fallback to announce the role/heading description. + CharSequence nodeRoleDescription = + AccessibilityNodeFeedbackUtils.getNodeRoleDescription(node, context, globalVariables); + if (!TextUtils.isEmpty(nodeRoleDescription)) { + outputJoinList.add(nodeRoleDescription); + logString.append(String.format("\n nodeRoleDescription={%s}", nodeRoleDescription)); + } else { + outputJoinList.add(context.getString(R.string.heading_template)); + logString.append("\n heading"); + } } } diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRuleForTV.java b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRuleForTV.java index bfac0bf64..a76d85486 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRuleForTV.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRuleForTV.java @@ -105,26 +105,29 @@ public static CharSequence viewAccessibilityFocusedDescriptionForTv( } } - CharSequence collectionItemTransition = - speakCollectionInfo ? globalVariables.getCollectionItemTransitionDescription(node) : ""; - if (!TextUtils.isEmpty(collectionItemTransition)) { - outputJoinList.add(collectionItemTransition); - logString.append( - String.format("\n collectionItemTransition={%s}", collectionItemTransition)); - } else if (speakRoles - && !WebInterfaceUtils.isWebContainer(node) - && AccessibilityNodeInfoUtils.isHeading(node)) { - // If the source node has collection item transition, collectionItemTransition text would - // not be empty. And TalkBack should announce the collection item transition information or it - // should fallback to announce the role/heading description. - CharSequence nodeRoleDescription = - AccessibilityNodeFeedbackUtils.getNodeRoleDescription(node, context, globalVariables); - if (!TextUtils.isEmpty(nodeRoleDescription)) { - outputJoinList.add(nodeRoleDescription); - logString.append(String.format("\n nodeRoleDescription={%s}", nodeRoleDescription)); - } else { - outputJoinList.add(context.getString(R.string.heading_template)); - logString.append("\n heading"); + boolean isTableItem = globalVariables.isFocusedNodeInTable(node); + if (!isTableItem) { + CharSequence collectionItemTransition = + speakCollectionInfo ? globalVariables.getCollectionItemTransitionDescription(node) : ""; + if (!TextUtils.isEmpty(collectionItemTransition)) { + outputJoinList.add(collectionItemTransition); + logString.append( + String.format("\n collectionItemTransition={%s}", collectionItemTransition)); + } else if (speakRoles + && !WebInterfaceUtils.isWebContainer(node) + && AccessibilityNodeInfoUtils.isHeading(node)) { + // If the source node has collection item transition, collectionItemTransition text would + // not be empty. And TalkBack should announce the collection item transition information or it + // should fallback to announce the role/heading description. + CharSequence nodeRoleDescription = + AccessibilityNodeFeedbackUtils.getNodeRoleDescription(node, context, globalVariables); + if (!TextUtils.isEmpty(nodeRoleDescription)) { + outputJoinList.add(nodeRoleDescription); + logString.append(String.format("\n nodeRoleDescription={%s}", nodeRoleDescription)); + } else { + outputJoinList.add(context.getString(R.string.heading_template)); + logString.append("\n heading"); + } } } @@ -135,10 +138,11 @@ public static CharSequence viewAccessibilityFocusedDescriptionForTv( CharSequence eventDescription = AccessibilityEventFeedbackUtils.getEventContentDescriptionOrEventAggregateText( event, preferredLocale); + CharSequence contentDescription; if (!TextUtils.isEmpty(nodeUnlabelledState)) { CharSequence unlabelledDescription = TextUtils.isEmpty(eventDescription) ? nodeUnlabelledState : eventDescription; - outputJoinList.add(unlabelledDescription); + contentDescription = unlabelledDescription; logString .append(String.format("\n unlabelledDescription={%s}", unlabelledDescription)) .append(String.format(", eventDescription={%s}", eventDescription)); @@ -146,14 +150,27 @@ public static CharSequence viewAccessibilityFocusedDescriptionForTv( CharSequence nodeTreeDescription = treeNodesDescription.aggregateNodeTreeDescription(node, event); if (!TextUtils.isEmpty(nodeTreeDescription)) { - outputJoinList.add(nodeTreeDescription); + contentDescription = nodeTreeDescription; logString.append(String.format("\n nodeTreeDescription={%s}", nodeTreeDescription)); } else { - outputJoinList.add(eventDescription); + contentDescription = eventDescription; logString.append(String.format("\n eventDescription={%s}", eventDescription)); } } + if (isTableItem) { + CharSequence tableCellFeedback = + globalVariables.getTableItemCellFeedback(node, contentDescription); + if (!TextUtils.isEmpty(tableCellFeedback)) { + outputJoinList.add(tableCellFeedback); + logString.append(String.format("\n tableCellFeedback={%s}", tableCellFeedback)); + } + } else { + if (!TextUtils.isEmpty(contentDescription)) { + outputJoinList.add(contentDescription); + } + } + // Add phonetic spelling if necessary. Optional phoneticExample = processorPhoneticLetters.getPhoneticLetterForKeyboardFocusEvent(event); diff --git a/talkback/src/main/res/values/donottranslate.xml b/talkback/src/main/res/values/donottranslate.xml index 907d29639..bdfaef77b 100644 --- a/talkback/src/main/res/values/donottranslate.xml +++ b/talkback/src/main/res/values/donottranslate.xml @@ -88,6 +88,9 @@ pref_capital_letters pref_a11y_hints pref_speak_container_element_positions_key + pref_table_headers_category_key + pref_table_column_headers_key + pref_table_speak_row_column_numbers_key pref_speak_time_key pref_on_device_ai pref_speak_roles_key @@ -730,6 +733,22 @@ pref_node_desc_order_value_role_name_state_pos pref_node_desc_order_value_name_role_state_pos + + + @string/pref_table_column_headers_entry_after + @string/pref_table_column_headers_entry_before + @string/pref_table_column_headers_entry_off + + + @string/pref_table_column_headers_value_after + @string/pref_table_column_headers_value_before + @string/pref_table_column_headers_value_off + + after + before + off + @string/pref_table_column_headers_value_after + @string/pref_time_feedback_format_entries_default @@ -1406,6 +1425,7 @@ true true true + true true true true diff --git a/talkback/src/main/res/values/strings.xml b/talkback/src/main/res/values/strings.xml index 12c91f2f3..b095ca021 100644 --- a/talkback/src/main/res/values/strings.xml +++ b/talkback/src/main/res/values/strings.xml @@ -415,6 +415,15 @@ --> Speak container info + + Table reading + Read column headers + After cell data + Before cell data + Do not read + Speak row and column numbers + Announce row and column numbers for table cells + + + + + + + + diff --git a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml index 1d7f69314..4848a1c5d 100644 --- a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml +++ b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml @@ -191,4 +191,24 @@ android:summary="@string/pref_speak_time_summary" app:splitSummary="true" /> + + + + + + + + diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java index a1b56e8fd..b40b7068a 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java @@ -2985,11 +2985,22 @@ public static boolean isHeading(AccessibilityNodeInfoCompat node) { } /** Returns whether the given node is a table root. */ - private static boolean isTableRoot(AccessibilityNodeInfoCompat node) { + public static boolean isTableRoot(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + if (Role.getRole(node) == Role.ROLE_GRID) { + return true; + } CollectionInfoCompat collectionInfo = node.getCollectionInfo(); - return collectionInfo != null - && collectionInfo.getRowCount() > 1 - && collectionInfo.getColumnCount() > 1; + if (collectionInfo == null) { + return false; + } + int rowCount = collectionInfo.getRowCount(); + int colCount = collectionInfo.getColumnCount(); + return (rowCount > 1 && colCount > 1) + || (colCount > 1 && rowCount == -1) + || (rowCount > 1 && colCount == -1); } /** Returns a table cell under table containing the given node. */ @@ -3011,7 +3022,7 @@ private static boolean isVoiceDictationNode(AccessibilityNodeInfoCompat node) { } /** Returns whether the given node is a table cell. */ - private static boolean isTableCell(AccessibilityNodeInfoCompat node) { + public static boolean isTableCell(AccessibilityNodeInfoCompat node) { CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo(); return collectionItemInfo != null && collectionItemInfo.getRowIndex() >= 0 @@ -3019,7 +3030,10 @@ private static boolean isTableCell(AccessibilityNodeInfoCompat node) { } /** Returns whether the given node is a table cell in a table. */ - private static boolean isTableCellUnderTable(AccessibilityNodeInfoCompat node) { + public static boolean isTableCellUnderTable(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo(); return collectionItemInfo != null && collectionItemInfo.getRowIndex() >= 0 diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java index 2d6b2fdea..67780f133 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java @@ -396,9 +396,24 @@ public int getRowColumnTransition() { */ @Role.RoleName public int getCollectionRole() { + if (mCollectionRoot == null) { + return Role.ROLE_NONE; + } + if (Role.getRole(mCollectionRoot) == Role.ROLE_GRID + || AccessibilityNodeInfoUtils.isTableRoot(mCollectionRoot)) { + return Role.ROLE_GRID; + } return Role.getRole(mCollectionRoot); } + public SparseArray getColumnHeaders() { + return mColumnHeaders; + } + + public SparseArray getRowHeaders() { + return mRowHeaders; + } + public @Nullable CharSequence getCollectionRoleDescription() { return (mCollectionRoot == null) ? null : mCollectionRoot.getRoleDescription(); } @@ -606,6 +621,10 @@ public boolean doesCollectionExist() { AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( announcedNode, collectionRoot, AccessibilityNodeInfoUtils.FILTER_COLLECTION_ITEM); + if (collectionItemNode == null) { + collectionItemNode = AccessibilityNodeInfoUtils.getTableCellUnderTable(announcedNode); + } + if (collectionItemNode == null) { return null; } @@ -622,9 +641,28 @@ public boolean doesCollectionExist() { if (rowName == null) { rowName = AccessibilityNodeInfoUtils.getGridRowTitle(collectionItemNode); } + if (rowName != null && rowIndex != -1 && rowHeaders.get(rowIndex) == null) { + rowHeaders.put(rowIndex, rowName); + } if (columnName == null) { columnName = AccessibilityNodeInfoUtils.getGridColumnTitle(collectionItemNode); } + if (columnName != null && columnIndex != -1 && columnHeaders.get(columnIndex) == null) { + columnHeaders.put(columnIndex, columnName); + } + if (item.isHeading() || AccessibilityNodeInfoUtils.isHeading(collectionItemNode)) { + CharSequence hText = getHeaderText(collectionItemNode); + if (!TextUtils.isEmpty(hText)) { + if (rowIndex == 0 && columnIndex != -1 && columnHeaders.get(columnIndex) == null) { + columnHeaders.put(columnIndex, hText); + columnName = hText; + } + if (columnIndex == 0 && rowIndex != -1 && rowHeaders.get(rowIndex) == null) { + rowHeaders.put(rowIndex, hText); + rowName = hText; + } + } + } return new TableItemState(heading, rowName, columnName, roleDescription, rowIndex, columnIndex); } @@ -765,7 +803,9 @@ public void updateCollectionInformation( mCollectionLevel = getCollectionLevelInternal(newCollectionRoot); ItemState newItemState = null; - if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { + boolean isGrid = (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) + || AccessibilityNodeInfoUtils.isTableRoot(newCollectionRoot); + if (isGrid) { // Cache the row and column headers. updateTableHeaderInfo(newCollectionRoot, mRowHeaders, mColumnHeaders); @@ -792,7 +832,9 @@ public void updateCollectionInformation( case NAVIGATE_INTERIOR: { ItemState newItemState = null; - if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { + boolean isGrid = (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) + || AccessibilityNodeInfoUtils.isTableRoot(newCollectionRoot); + if (isGrid) { newItemState = getTableItemState(newCollectionRoot, announcedNode, mRowHeaders, mColumnHeaders); } else if (Role.getRole(newCollectionRoot) == Role.ROLE_LIST) { @@ -850,25 +892,28 @@ private static void updateTableHeaderInfo( 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 inside table - // rows so they are nested two levels down. CollectionInfoCompat collectionInfo = collectionRoot.getCollectionInfo(); - int numChildren = collectionRoot.getChildCount(); + searchTableHeaders(collectionRoot, collectionInfo, rowHeaders, columnHeaders, /* depth= */ 0); + } + + private static void searchTableHeaders( + AccessibilityNodeInfoCompat node, + CollectionInfoCompat collectionInfo, + SparseArray rowHeaders, + SparseArray columnHeaders, + int depth) { + if (node == null || depth > 4) { + return; + } + int numChildren = node.getChildCount(); for (int i = 0; i < numChildren; ++i) { - AccessibilityNodeInfoCompat child = collectionRoot.getChild(i); + AccessibilityNodeInfoCompat child = node.getChild(i); if (child == null) { continue; } - if (!updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders)) { - int numGrandchildren = child.getChildCount(); - for (int j = 0; j < numGrandchildren; ++j) { - AccessibilityNodeInfoCompat grandchild = child.getChild(j); - if (grandchild == null) { - continue; - } - updateSingleTableHeader(grandchild, collectionInfo, rowHeaders, columnHeaders); - } + boolean handled = updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders); + if (!handled && depth < 4) { + searchTableHeaders(child, collectionInfo, rowHeaders, columnHeaders, depth + 1); } } } @@ -884,16 +929,24 @@ private static boolean updateSingleTableHeader( CharSequence headingName = getHeaderText(node); CollectionItemInfoCompat itemInfo = node.getCollectionItemInfo(); - if (itemInfo != null && headingName != null) { - @TableHeadingType int headingType = getTableHeadingType(node, itemInfo, collectionInfo); - if ((headingType & TYPE_ROW) != 0) { - rowHeaders.put(itemInfo.getRowIndex(), headingName); + if (itemInfo != null) { + if (TextUtils.isEmpty(headingName)) { + headingName = AccessibilityNodeInfoUtils.getGridColumnTitle(node); } - if ((headingType & TYPE_COLUMN) != 0) { - columnHeaders.put(itemInfo.getColumnIndex(), headingName); + if (TextUtils.isEmpty(headingName)) { + headingName = AccessibilityNodeInfoUtils.getGridRowTitle(node); } + if (!TextUtils.isEmpty(headingName)) { + @TableHeadingType int headingType = getTableHeadingType(node, 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 headingType != TYPE_NONE; + } } return false; @@ -904,15 +957,17 @@ private static boolean updateSingleTableHeader( * 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}. */ public static @Nullable CharSequence getHeaderText(AccessibilityNodeInfoCompat node) { if (node == null) { return null; } + CharSequence directText = AccessibilityNodeInfoUtils.getNodeText(node); + if (!TextUtils.isEmpty(directText)) { + return directText; + } + Set visitedNodes = new HashSet<>(); AccessibilityNodeInfoCompat currentNode = node; while (currentNode != null) { @@ -922,15 +977,30 @@ private static boolean updateSingleTableHeader( } CharSequence nodeText = AccessibilityNodeInfoUtils.getNodeText(currentNode); - if (nodeText != null) { + if (!TextUtils.isEmpty(nodeText)) { return nodeText; } - if (currentNode.getChildCount() != 1) { + int childCount = currentNode.getChildCount(); + if (childCount == 0) { return null; } - currentNode = currentNode.getChild(0); + AccessibilityNodeInfoCompat nextNode = null; + for (int i = 0; i < childCount; i++) { + AccessibilityNodeInfoCompat child = currentNode.getChild(i); + if (child != null) { + CharSequence childText = AccessibilityNodeInfoUtils.getNodeText(child); + if (!TextUtils.isEmpty(childText)) { + return childText; + } + if (nextNode == null && child.getChildCount() > 0) { + nextNode = child; + } + } + } + + currentNode = nextNode; } return null; @@ -940,7 +1010,7 @@ private static boolean updateSingleTableHeader( * 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 + * @return {@code TYPE_ROW} or {@code 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. */ @@ -949,18 +1019,24 @@ private static int getTableHeadingType( @NonNull AccessibilityNodeInfoCompat node, @NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { - if (AccessibilityNodeInfoUtils.isHeading(node)) { - if (item.getRowSpan() == 1 && item.getColumnSpan() == 1) { - if (getRowIndex(item, collection) == 0 && collection.getColumnCount() > 1) { - return TYPE_COLUMN; - } - if (getColumnIndex(item, collection) == 0 && collection.getRowCount() > 1) { - return TYPE_ROW; - } + int rowIndex = getRowIndex(item, collection); + int colIndex = getColumnIndex(item, collection); + if (AccessibilityNodeInfoUtils.isHeading(node) || item.isHeading()) { + if (rowIndex == 0 && (collection.getColumnCount() > 1 || collection.getColumnCount() == -1)) { + return TYPE_COLUMN; + } + if (colIndex == 0 && (collection.getRowCount() > 1 || collection.getRowCount() == -1)) { + return TYPE_ROW; } return TYPE_INDETERMINATE; } + if (rowIndex == 0 + && (collection.getColumnCount() > 1 || collection.getColumnCount() == -1) + && (collection.getRowCount() > 1 || collection.getRowCount() == -1)) { + return TYPE_COLUMN; + } + return TYPE_NONE; } From 2e5d934a80c4bd2e47645fb4df7c0079b161ef0a Mon Sep 17 00:00:00 2001 From: nab5 Date: Thu, 8 Oct 2026 05:58:52 +0530 Subject: [PATCH 2/4] Ignore missing translations for new table reading preference strings --- talkback/src/main/res/values/strings.xml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/talkback/src/main/res/values/strings.xml b/talkback/src/main/res/values/strings.xml index b095ca021..8263a4084 100644 --- a/talkback/src/main/res/values/strings.xml +++ b/talkback/src/main/res/values/strings.xml @@ -416,13 +416,13 @@ Speak container info - Table reading - Read column headers - After cell data - Before cell data - Do not read - Speak row and column numbers - Announce row and column numbers for table cells + Table reading + Read column headers + After cell data + Before cell data + Do not read + Speak row and column numbers + Announce row and column numbers for table cells + + - - diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableReadingPreferencesTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableReadingPreferencesTest.kt new file mode 100644 index 000000000..aa678961e --- /dev/null +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableReadingPreferencesTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2026 Backtalk contributors + * + * 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.google.android.accessibility.talkback.compositor + +import com.google.android.accessibility.talkback.utils.VerbosityPreferences +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +class TableReadingPreferencesTest { + + @Test + fun preferenceConstantsMatchExpectedValues() { + assertEquals("before", GlobalVariables.TABLE_HEADERS_BEFORE) + assertEquals("after", GlobalVariables.TABLE_HEADERS_AFTER) + assertEquals("off", GlobalVariables.TABLE_HEADERS_OFF) + } + + @Test + fun verbosityCustomKeyGeneration() { + val key = "pref_table_speak_row_column_numbers_key" + val customKey = VerbosityPreferences.toVerbosityPrefKey("pref_verbosity_preset_value_custom", key) + assertEquals("pref_verbosity_preset_value_custom_pref_table_speak_row_column_numbers_key", customKey) + } + + @Test + fun rowColumnNumbersSwitchIsInsidePresetCategoryInPreferencesXml() { + for (xmlPath in listOf( + "src/main/res/xml/verbosity_preferences.xml", + "src/wear/res/xml-watch/verbosity_preferences.xml" + )) { + val file = File(xmlPath).let { if (it.exists()) it else File("talkback", xmlPath) } + assertTrue("File should exist: $xmlPath", file.exists()) + + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val allElements = doc.getElementsByTagName("*") + var foundInPresetCategory = false + + for (i in 0 until allElements.length) { + val cat = allElements.item(i) as Element + if (cat.getAttribute("android:key") == "@string/pref_verbosity_category_preset_settings_key") { + val switches = cat.getElementsByTagName("*") + for (j in 0 until switches.length) { + val sw = switches.item(j) as Element + if (sw.getAttribute("android:key") == "@string/pref_table_speak_row_column_numbers_key") { + foundInPresetCategory = true + break + } + } + } + } + + assertTrue( + "pref_table_speak_row_column_numbers_key must be inside pref_verbosity_category_preset_settings_key in $xmlPath", + foundInPresetCategory + ) + } + } + + @Test + fun columnHeadersValuesIncludeAllConstantsInDonottranslateXml() { + val file = File("talkback/src/main/res/values/donottranslate.xml").let { + if (it.exists()) it else File("src/main/res/values/donottranslate.xml") + } + assertTrue(file.exists()) + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val arrays = doc.getElementsByTagName("string-array") + var foundValuesArray = false + + for (i in 0 until arrays.length) { + val arr = arrays.item(i) as Element + if (arr.getAttribute("name") == "pref_table_column_headers_values") { + foundValuesArray = true + val items = arr.getElementsByTagName("item") + val values = (0 until items.length).map { items.item(it).textContent.trim() } + assertEquals( + listOf( + "@string/pref_table_column_headers_value_after", + "@string/pref_table_column_headers_value_before", + "@string/pref_table_column_headers_value_off" + ), + values + ) + } + } + assertTrue(foundValuesArray) + } + + @Test + fun verbosityPrefFragmentBuildMapIncludesTableNumbersSwitch() { + val file = File("talkback/src/main/java/com/google/android/accessibility/talkback/preference/base/VerbosityPrefFragment.java").let { + if (it.exists()) it else File("src/main/java/com/google/android/accessibility/talkback/preference/base/VerbosityPrefFragment.java") + } + assertTrue(file.exists()) + val content = file.readText() + assertTrue( + "VerbosityPrefFragment.buildMap() must include pref_table_speak_row_column_numbers_key", + content.contains("pref_table_speak_row_column_numbers_key") + ) + } + + @Test + fun differencesMdDocumentsTableReadingFeatures() { + val file = File("../differences.md").let { + if (it.exists()) it else File("differences.md") + } + assertTrue("differences.md should exist", file.exists()) + val text = file.readText() + assertTrue(text.contains("Table column headers")) + assertTrue(text.contains("Speak row and column numbers")) + } +} diff --git a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml index 4848a1c5d..c6fa941af 100644 --- a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml +++ b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml @@ -31,6 +31,13 @@ android:key="@string/pref_speak_container_element_positions_key" android:title="@string/pref_speak_container_element_positions_title" /> + + + - - diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java index b40b7068a..a1b56e8fd 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java @@ -2985,22 +2985,11 @@ public static boolean isHeading(AccessibilityNodeInfoCompat node) { } /** Returns whether the given node is a table root. */ - public static boolean isTableRoot(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } - if (Role.getRole(node) == Role.ROLE_GRID) { - return true; - } + private static boolean isTableRoot(AccessibilityNodeInfoCompat node) { CollectionInfoCompat collectionInfo = node.getCollectionInfo(); - if (collectionInfo == null) { - return false; - } - int rowCount = collectionInfo.getRowCount(); - int colCount = collectionInfo.getColumnCount(); - return (rowCount > 1 && colCount > 1) - || (colCount > 1 && rowCount == -1) - || (rowCount > 1 && colCount == -1); + return collectionInfo != null + && collectionInfo.getRowCount() > 1 + && collectionInfo.getColumnCount() > 1; } /** Returns a table cell under table containing the given node. */ @@ -3022,7 +3011,7 @@ private static boolean isVoiceDictationNode(AccessibilityNodeInfoCompat node) { } /** Returns whether the given node is a table cell. */ - public static boolean isTableCell(AccessibilityNodeInfoCompat node) { + private static boolean isTableCell(AccessibilityNodeInfoCompat node) { CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo(); return collectionItemInfo != null && collectionItemInfo.getRowIndex() >= 0 @@ -3030,10 +3019,7 @@ public static boolean isTableCell(AccessibilityNodeInfoCompat node) { } /** Returns whether the given node is a table cell in a table. */ - public static boolean isTableCellUnderTable(@Nullable AccessibilityNodeInfoCompat node) { - if (node == null) { - return false; - } + private static boolean isTableCellUnderTable(AccessibilityNodeInfoCompat node) { CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo(); return collectionItemInfo != null && collectionItemInfo.getRowIndex() >= 0 diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java index 67780f133..ccc535a56 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java @@ -396,24 +396,9 @@ public int getRowColumnTransition() { */ @Role.RoleName public int getCollectionRole() { - if (mCollectionRoot == null) { - return Role.ROLE_NONE; - } - if (Role.getRole(mCollectionRoot) == Role.ROLE_GRID - || AccessibilityNodeInfoUtils.isTableRoot(mCollectionRoot)) { - return Role.ROLE_GRID; - } return Role.getRole(mCollectionRoot); } - public SparseArray getColumnHeaders() { - return mColumnHeaders; - } - - public SparseArray getRowHeaders() { - return mRowHeaders; - } - public @Nullable CharSequence getCollectionRoleDescription() { return (mCollectionRoot == null) ? null : mCollectionRoot.getRoleDescription(); } @@ -621,10 +606,6 @@ public boolean doesCollectionExist() { AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor( announcedNode, collectionRoot, AccessibilityNodeInfoUtils.FILTER_COLLECTION_ITEM); - if (collectionItemNode == null) { - collectionItemNode = AccessibilityNodeInfoUtils.getTableCellUnderTable(announcedNode); - } - if (collectionItemNode == null) { return null; } @@ -635,34 +616,38 @@ public boolean doesCollectionExist() { @TableHeadingType int heading = getTableHeadingType(collectionItemNode, item, collection); int rowIndex = getRowIndex(item, collection); int columnIndex = getColumnIndex(item, collection); + + if (columnHeaders.size() == 0 && rowHeaders.size() == 0) { + updateTableHeaderInfo(collectionRoot, rowHeaders, columnHeaders); + } + if ((heading & TYPE_ROW) != 0 && rowIndex != -1 && rowHeaders.get(rowIndex) == null) { + CharSequence headingName = getHeaderText(collectionItemNode); + if (TextUtils.isEmpty(headingName)) { + headingName = AccessibilityNodeInfoUtils.getGridRowTitle(collectionItemNode); + } + if (!TextUtils.isEmpty(headingName)) { + rowHeaders.put(rowIndex, headingName); + } + } + if ((heading & TYPE_COLUMN) != 0 && columnIndex != -1 && columnHeaders.get(columnIndex) == null) { + CharSequence headingName = getHeaderText(collectionItemNode); + if (TextUtils.isEmpty(headingName)) { + headingName = AccessibilityNodeInfoUtils.getGridColumnTitle(collectionItemNode); + } + if (!TextUtils.isEmpty(headingName)) { + columnHeaders.put(columnIndex, headingName); + } + } + CharSequence rowName = rowIndex != -1 ? rowHeaders.get(rowIndex) : null; CharSequence columnName = columnIndex != -1 ? columnHeaders.get(columnIndex) : null; CharSequence roleDescription = collectionItemNode.getRoleDescription(); if (rowName == null) { rowName = AccessibilityNodeInfoUtils.getGridRowTitle(collectionItemNode); } - if (rowName != null && rowIndex != -1 && rowHeaders.get(rowIndex) == null) { - rowHeaders.put(rowIndex, rowName); - } if (columnName == null) { columnName = AccessibilityNodeInfoUtils.getGridColumnTitle(collectionItemNode); } - if (columnName != null && columnIndex != -1 && columnHeaders.get(columnIndex) == null) { - columnHeaders.put(columnIndex, columnName); - } - if (item.isHeading() || AccessibilityNodeInfoUtils.isHeading(collectionItemNode)) { - CharSequence hText = getHeaderText(collectionItemNode); - if (!TextUtils.isEmpty(hText)) { - if (rowIndex == 0 && columnIndex != -1 && columnHeaders.get(columnIndex) == null) { - columnHeaders.put(columnIndex, hText); - columnName = hText; - } - if (columnIndex == 0 && rowIndex != -1 && rowHeaders.get(rowIndex) == null) { - rowHeaders.put(rowIndex, hText); - rowName = hText; - } - } - } return new TableItemState(heading, rowName, columnName, roleDescription, rowIndex, columnIndex); } @@ -803,9 +788,7 @@ public void updateCollectionInformation( mCollectionLevel = getCollectionLevelInternal(newCollectionRoot); ItemState newItemState = null; - boolean isGrid = (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) - || AccessibilityNodeInfoUtils.isTableRoot(newCollectionRoot); - if (isGrid) { + if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { // Cache the row and column headers. updateTableHeaderInfo(newCollectionRoot, mRowHeaders, mColumnHeaders); @@ -832,9 +815,7 @@ public void updateCollectionInformation( case NAVIGATE_INTERIOR: { ItemState newItemState = null; - boolean isGrid = (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) - || AccessibilityNodeInfoUtils.isTableRoot(newCollectionRoot); - if (isGrid) { + if (Role.getRole(newCollectionRoot) == Role.ROLE_GRID) { newItemState = getTableItemState(newCollectionRoot, announcedNode, mRowHeaders, mColumnHeaders); } else if (Role.getRole(newCollectionRoot) == Role.ROLE_LIST) { @@ -911,8 +892,9 @@ private static void searchTableHeaders( if (child == null) { continue; } - boolean handled = updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders); - if (!handled && depth < 4) { + boolean isCell = child.getCollectionItemInfo() != null; + boolean isHeader = updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders); + if (!isCell && !isHeader && depth < 4) { searchTableHeaders(child, collectionInfo, rowHeaders, columnHeaders, depth + 1); } } @@ -928,25 +910,23 @@ private static boolean updateSingleTableHeader( } CharSequence headingName = getHeaderText(node); + if (TextUtils.isEmpty(headingName)) { + headingName = AccessibilityNodeInfoUtils.getGridColumnTitle(node); + } + if (TextUtils.isEmpty(headingName)) { + headingName = AccessibilityNodeInfoUtils.getGridRowTitle(node); + } CollectionItemInfoCompat itemInfo = node.getCollectionItemInfo(); - if (itemInfo != null) { - if (TextUtils.isEmpty(headingName)) { - headingName = AccessibilityNodeInfoUtils.getGridColumnTitle(node); + if (itemInfo != null && !TextUtils.isEmpty(headingName)) { + @TableHeadingType int headingType = getTableHeadingType(node, itemInfo, collectionInfo); + if ((headingType & TYPE_ROW) != 0) { + rowHeaders.put(itemInfo.getRowIndex(), headingName); } - if (TextUtils.isEmpty(headingName)) { - headingName = AccessibilityNodeInfoUtils.getGridRowTitle(node); + if ((headingType & TYPE_COLUMN) != 0) { + columnHeaders.put(itemInfo.getColumnIndex(), headingName); } - if (!TextUtils.isEmpty(headingName)) { - @TableHeadingType int headingType = getTableHeadingType(node, 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 headingType != TYPE_NONE; } return false; @@ -957,17 +937,15 @@ private static boolean updateSingleTableHeader( * 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}. */ public static @Nullable CharSequence getHeaderText(AccessibilityNodeInfoCompat node) { if (node == null) { return null; } - CharSequence directText = AccessibilityNodeInfoUtils.getNodeText(node); - if (!TextUtils.isEmpty(directText)) { - return directText; - } - Set visitedNodes = new HashSet<>(); AccessibilityNodeInfoCompat currentNode = node; while (currentNode != null) { @@ -977,30 +955,15 @@ private static boolean updateSingleTableHeader( } CharSequence nodeText = AccessibilityNodeInfoUtils.getNodeText(currentNode); - if (!TextUtils.isEmpty(nodeText)) { + if (nodeText != null) { return nodeText; } - int childCount = currentNode.getChildCount(); - if (childCount == 0) { + if (currentNode.getChildCount() != 1) { return null; } - AccessibilityNodeInfoCompat nextNode = null; - for (int i = 0; i < childCount; i++) { - AccessibilityNodeInfoCompat child = currentNode.getChild(i); - if (child != null) { - CharSequence childText = AccessibilityNodeInfoUtils.getNodeText(child); - if (!TextUtils.isEmpty(childText)) { - return childText; - } - if (nextNode == null && child.getChildCount() > 0) { - nextNode = child; - } - } - } - - currentNode = nextNode; + currentNode = currentNode.getChild(0); } return null; @@ -1010,7 +973,7 @@ private static boolean updateSingleTableHeader( * 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 {@code TYPE_COLUMN} for row or column headers; {@code + * @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. */ @@ -1019,24 +982,18 @@ private static int getTableHeadingType( @NonNull AccessibilityNodeInfoCompat node, @NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { - int rowIndex = getRowIndex(item, collection); - int colIndex = getColumnIndex(item, collection); if (AccessibilityNodeInfoUtils.isHeading(node) || item.isHeading()) { - if (rowIndex == 0 && (collection.getColumnCount() > 1 || collection.getColumnCount() == -1)) { - return TYPE_COLUMN; - } - if (colIndex == 0 && (collection.getRowCount() > 1 || collection.getRowCount() == -1)) { - return TYPE_ROW; + if (item.getRowSpan() == 1 && item.getColumnSpan() == 1) { + if (getRowIndex(item, collection) == 0 && collection.getColumnCount() > 1) { + return TYPE_COLUMN; + } + if (getColumnIndex(item, collection) == 0 && collection.getRowCount() > 1) { + return TYPE_ROW; + } } return TYPE_INDETERMINATE; } - if (rowIndex == 0 - && (collection.getColumnCount() > 1 || collection.getColumnCount() == -1) - && (collection.getRowCount() > 1 || collection.getRowCount() == -1)) { - return TYPE_COLUMN; - } - return TYPE_NONE; } From 72c70dc8305d273fc6e0e639661fad98933642a4 Mon Sep 17 00:00:00 2001 From: Aryan Choudhary Date: Fri, 9 Oct 2026 17:59:00 +0530 Subject: [PATCH 4/4] Keep table header detection as it was, and test the table reading settings - Restore CollectionState's header detection, which table navigation and braille also use - Say "heading" for a heading in a grid when headers are read before cell data - Replace the tests that searched source files with tests of what a table cell says - Remove an unused import --- talkback/build.gradle | 3 + .../talkback/compositor/GlobalVariables.java | 1 - ...eViewAccessibilityFocusedFeedbackRule.java | 14 +- .../TableItemTransitionDescriptionTest.kt | 174 ++++++++++++++++++ .../compositor/TableReadingPreferencesTest.kt | 129 ------------- .../utils/monitor/CollectionState.java | 65 ++----- 6 files changed, 200 insertions(+), 186 deletions(-) create mode 100644 talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableItemTransitionDescriptionTest.kt delete mode 100644 talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableReadingPreferencesTest.kt diff --git a/talkback/build.gradle b/talkback/build.gradle index b788bd999..dadd961b0 100644 --- a/talkback/build.gradle +++ b/talkback/build.gradle @@ -19,6 +19,7 @@ dependencies { implementation libs.litertlm testImplementation libs.junit testImplementation libs.json + testImplementation libs.robolectric } // Gemini settings come from local.properties, which is not committed, so each builder uses their own key. @@ -40,4 +41,6 @@ android { buildConfigField("String", "COMMIT_HASH", '"' + rootProject.gitCommitHash + '"') buildConfigField("int", "COMMIT_COUNT", String.valueOf(rootProject.gitCommitCount)) } + // Robolectric tests read the module's strings. + testOptions.unitTests.includeAndroidResources = true } diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java index 36ddd2b38..75e97e1f9 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/GlobalVariables.java @@ -51,7 +51,6 @@ import com.google.android.accessibility.utils.FormFactorUtils; import com.google.android.accessibility.utils.KeyboardUtils; import com.google.android.accessibility.utils.Logger; -import com.google.android.accessibility.utils.Role; import com.google.android.accessibility.utils.TimedFlags; import com.google.android.accessibility.utils.input.WindowsDelegate; import com.google.android.accessibility.utils.monitor.CollectionState; diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java index 2bbef3cca..90190e410 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/compositor/rule/EventTypeViewAccessibilityFocusedFeedbackRule.java @@ -287,13 +287,13 @@ private static CharSequence viewAccessibilityFocusedDescription( CharSequence collectionItemTransition = speakCollectionInfo ? globalVariables.getCollectionItemTransitionDescription(node) : ""; - boolean isTableGrid = (globalVariables.getCollectionRole() == Role.ROLE_GRID); - boolean headersBefore = - isTableGrid - && GlobalVariables.TABLE_HEADERS_BEFORE.equals( - globalVariables.getTableColumnHeaders()); + // In a table, the row and column can be spoken before the cell's contents. + boolean transitionBeforeContent = + !TextUtils.isEmpty(collectionItemTransition) + && globalVariables.getCollectionRole() == Role.ROLE_GRID + && GlobalVariables.TABLE_HEADERS_BEFORE.equals(globalVariables.getTableColumnHeaders()); - if (headersBefore && !TextUtils.isEmpty(collectionItemTransition)) { + if (transitionBeforeContent) { outputJoinList.add(collectionItemTransition); logString.append( String.format("\n collectionItemTransition={%s}", collectionItemTransition)); @@ -310,7 +310,7 @@ private static CharSequence viewAccessibilityFocusedDescription( logString.append(String.format("\n phoneticExample={%s}", phoneticExample)); // Prepare Collection item transition state or Node role/heading description for feedback. - if (!headersBefore) { + if (!transitionBeforeContent) { if (!TextUtils.isEmpty(collectionItemTransition)) { outputJoinList.add(collectionItemTransition); logString.append( diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableItemTransitionDescriptionTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableItemTransitionDescriptionTest.kt new file mode 100644 index 000000000..2fc7e6938 --- /dev/null +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableItemTransitionDescriptionTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Backtalk contributors + * + * 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.google.android.accessibility.talkback.compositor + +import android.content.Context +import android.text.SpannableString +import android.text.Spanned +import android.text.style.LocaleSpan +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat +import com.google.android.accessibility.talkback.R +import com.google.android.accessibility.utils.monitor.CollectionState +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** What a table cell says about its row and column, for each of the table reading settings. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TableItemTransitionDescriptionTest { + private lateinit var context: Context + private val state = CollectionState() + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + } + + /** A 3 by 3 table. With [headers], the first row holds them, as a web table's does. */ + private fun table(headers: List?): List> { + val root = AccessibilityNodeInfo.obtain() + AccessibilityNodeInfoCompat.wrap(root) + .setCollectionInfo(CollectionInfoCompat.obtain(3, 3, /* hierarchical= */ false)) + val text = + listOf( + headers ?: listOf("Apple", "Red", "3"), + listOf("Banana", "Yellow", "5"), + listOf("Cherry", "Dark red", "20"), + ) + return text.mapIndexed { row, cells -> + cells.mapIndexed { column, cellText -> + val isHeader = headers != null && row == 0 + val cell = AccessibilityNodeInfo.obtain() + shadowOf(root).addChild(cell) + AccessibilityNodeInfoCompat.wrap(cell).apply { + setText(cellText) + setHeading(isHeader) + setCollectionItemInfo(CollectionItemInfoCompat.obtain(row, 1, column, 1, isHeader)) + } + } + } + } + + private fun describe( + cell: AccessibilityNodeInfoCompat, + columnHeaders: String = GlobalVariables.TABLE_HEADERS_AFTER, + numbers: Boolean = true, + ): CharSequence { + state.updateCollectionInformation( + cell, AccessibilityEvent.obtain(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)) + return CollectionStateFeedbackUtils.getCollectionItemTransitionDescription( + cell, state, context, columnHeaders, numbers) + } + + private fun row(number: Int) = context.getString(R.string.row_index_template, number) + + private fun column(number: Int) = context.getString(R.string.column_index_template, number) + + @Test + fun defaults_sayTheSameAsTheDescriptionWithoutSettings() { + val cells = table(headers = listOf("Name", "Colour", "Count")) + for (cell in listOf(cells[1][0], cells[1][1], cells[2][1], cells[0][2])) { + state.updateCollectionInformation( + cell, AccessibilityEvent.obtain(AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)) + assertEquals( + CollectionStateFeedbackUtils.getCollectionItemTransitionDescription(cell, state, context) + .toString(), + CollectionStateFeedbackUtils.getCollectionItemTransitionDescription( + cell, state, context, GlobalVariables.TABLE_HEADERS_AFTER, true) + .toString()) + } + } + + @Test + fun headers_areSpokenForTheColumnThatChanged() { + val cells = table(headers = listOf("Name", "Colour", "Count")) + assertEquals("${row(2)}, Name", describe(cells[1][0]).toString()) + assertEquals("Colour", describe(cells[1][1]).toString()) + assertEquals(row(3), describe(cells[2][1]).toString()) + } + + @Test + fun headerCell_saysItIsAColumnHeading() { + val cells = table(headers = listOf("Name", "Colour", "Count")) + assertEquals( + "${context.getString(R.string.column_heading_template)}, ${row(1)}", + describe(cells[0][0]).toString()) + } + + @Test + fun tableWithoutHeaders_saysNumbersAndNeverTheFirstRow() { + val cells = table(headers = null) + assertEquals("${row(1)}, ${column(1)}", describe(cells[0][0]).toString()) + assertEquals(row(2), describe(cells[1][0]).toString()) + assertEquals(column(2), describe(cells[1][1]).toString()) + } + + @Test + fun headersOff_saysColumnNumbersInstead() { + val cells = table(headers = listOf("Name", "Colour", "Count")) + val off = GlobalVariables.TABLE_HEADERS_OFF + assertEquals("${row(2)}, ${column(1)}", describe(cells[1][0], off).toString()) + assertEquals(column(2), describe(cells[1][1], off).toString()) + } + + @Test + fun numbersOff_keepsHeadersAndDropsNumbers() { + val cells = table(headers = listOf("Name", "Colour", "Count")) + assertEquals("Name", describe(cells[1][0], numbers = false).toString()) + assertEquals("Colour", describe(cells[1][1], numbers = false).toString()) + assertEquals("", describe(cells[2][1], numbers = false).toString()) + } + + @Test + fun headersOffAndNumbersOff_saysNothingForADataCell() { + val cells = table(headers = listOf("Name", "Colour", "Count")) + val off = GlobalVariables.TABLE_HEADERS_OFF + assertEquals("", describe(cells[1][0], off, numbers = false).toString()) + assertEquals("", describe(cells[1][1], off, numbers = false).toString()) + } + + @Test + fun numbersOff_inATableWithoutHeaders_saysNothing() { + val cells = table(headers = null) + assertEquals("", describe(cells[1][0], numbers = false).toString()) + assertEquals("", describe(cells[1][1], numbers = false).toString()) + } + + @Test + fun header_keepsItsLanguage() { + val french = SpannableString("Couleur") + french.setSpan(LocaleSpan(Locale.FRENCH), 0, french.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val cells = table(headers = listOf("Name", french, "Count")) + describe(cells[1][0]) + val spoken = describe(cells[1][1]) + + assertEquals("Couleur", spoken.toString()) + val spans = (spoken as Spanned).getSpans(0, spoken.length, LocaleSpan::class.java) + assertEquals(listOf(Locale.FRENCH), spans.map { it.locale }) + } +} diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableReadingPreferencesTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableReadingPreferencesTest.kt deleted file mode 100644 index aa678961e..000000000 --- a/talkback/src/test/java/com/google/android/accessibility/talkback/compositor/TableReadingPreferencesTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2026 Backtalk contributors - * - * 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.google.android.accessibility.talkback.compositor - -import com.google.android.accessibility.talkback.utils.VerbosityPreferences -import java.io.File -import javax.xml.parsers.DocumentBuilderFactory -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.w3c.dom.Element - -class TableReadingPreferencesTest { - - @Test - fun preferenceConstantsMatchExpectedValues() { - assertEquals("before", GlobalVariables.TABLE_HEADERS_BEFORE) - assertEquals("after", GlobalVariables.TABLE_HEADERS_AFTER) - assertEquals("off", GlobalVariables.TABLE_HEADERS_OFF) - } - - @Test - fun verbosityCustomKeyGeneration() { - val key = "pref_table_speak_row_column_numbers_key" - val customKey = VerbosityPreferences.toVerbosityPrefKey("pref_verbosity_preset_value_custom", key) - assertEquals("pref_verbosity_preset_value_custom_pref_table_speak_row_column_numbers_key", customKey) - } - - @Test - fun rowColumnNumbersSwitchIsInsidePresetCategoryInPreferencesXml() { - for (xmlPath in listOf( - "src/main/res/xml/verbosity_preferences.xml", - "src/wear/res/xml-watch/verbosity_preferences.xml" - )) { - val file = File(xmlPath).let { if (it.exists()) it else File("talkback", xmlPath) } - assertTrue("File should exist: $xmlPath", file.exists()) - - val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) - val allElements = doc.getElementsByTagName("*") - var foundInPresetCategory = false - - for (i in 0 until allElements.length) { - val cat = allElements.item(i) as Element - if (cat.getAttribute("android:key") == "@string/pref_verbosity_category_preset_settings_key") { - val switches = cat.getElementsByTagName("*") - for (j in 0 until switches.length) { - val sw = switches.item(j) as Element - if (sw.getAttribute("android:key") == "@string/pref_table_speak_row_column_numbers_key") { - foundInPresetCategory = true - break - } - } - } - } - - assertTrue( - "pref_table_speak_row_column_numbers_key must be inside pref_verbosity_category_preset_settings_key in $xmlPath", - foundInPresetCategory - ) - } - } - - @Test - fun columnHeadersValuesIncludeAllConstantsInDonottranslateXml() { - val file = File("talkback/src/main/res/values/donottranslate.xml").let { - if (it.exists()) it else File("src/main/res/values/donottranslate.xml") - } - assertTrue(file.exists()) - val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) - val arrays = doc.getElementsByTagName("string-array") - var foundValuesArray = false - - for (i in 0 until arrays.length) { - val arr = arrays.item(i) as Element - if (arr.getAttribute("name") == "pref_table_column_headers_values") { - foundValuesArray = true - val items = arr.getElementsByTagName("item") - val values = (0 until items.length).map { items.item(it).textContent.trim() } - assertEquals( - listOf( - "@string/pref_table_column_headers_value_after", - "@string/pref_table_column_headers_value_before", - "@string/pref_table_column_headers_value_off" - ), - values - ) - } - } - assertTrue(foundValuesArray) - } - - @Test - fun verbosityPrefFragmentBuildMapIncludesTableNumbersSwitch() { - val file = File("talkback/src/main/java/com/google/android/accessibility/talkback/preference/base/VerbosityPrefFragment.java").let { - if (it.exists()) it else File("src/main/java/com/google/android/accessibility/talkback/preference/base/VerbosityPrefFragment.java") - } - assertTrue(file.exists()) - val content = file.readText() - assertTrue( - "VerbosityPrefFragment.buildMap() must include pref_table_speak_row_column_numbers_key", - content.contains("pref_table_speak_row_column_numbers_key") - ) - } - - @Test - fun differencesMdDocumentsTableReadingFeatures() { - val file = File("../differences.md").let { - if (it.exists()) it else File("differences.md") - } - assertTrue("differences.md should exist", file.exists()) - val text = file.readText() - assertTrue(text.contains("Table column headers")) - assertTrue(text.contains("Speak row and column numbers")) - } -} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java index ccc535a56..2d6b2fdea 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java @@ -616,29 +616,6 @@ public boolean doesCollectionExist() { @TableHeadingType int heading = getTableHeadingType(collectionItemNode, item, collection); int rowIndex = getRowIndex(item, collection); int columnIndex = getColumnIndex(item, collection); - - if (columnHeaders.size() == 0 && rowHeaders.size() == 0) { - updateTableHeaderInfo(collectionRoot, rowHeaders, columnHeaders); - } - if ((heading & TYPE_ROW) != 0 && rowIndex != -1 && rowHeaders.get(rowIndex) == null) { - CharSequence headingName = getHeaderText(collectionItemNode); - if (TextUtils.isEmpty(headingName)) { - headingName = AccessibilityNodeInfoUtils.getGridRowTitle(collectionItemNode); - } - if (!TextUtils.isEmpty(headingName)) { - rowHeaders.put(rowIndex, headingName); - } - } - if ((heading & TYPE_COLUMN) != 0 && columnIndex != -1 && columnHeaders.get(columnIndex) == null) { - CharSequence headingName = getHeaderText(collectionItemNode); - if (TextUtils.isEmpty(headingName)) { - headingName = AccessibilityNodeInfoUtils.getGridColumnTitle(collectionItemNode); - } - if (!TextUtils.isEmpty(headingName)) { - columnHeaders.put(columnIndex, headingName); - } - } - CharSequence rowName = rowIndex != -1 ? rowHeaders.get(rowIndex) : null; CharSequence columnName = columnIndex != -1 ? columnHeaders.get(columnIndex) : null; CharSequence roleDescription = collectionItemNode.getRoleDescription(); @@ -873,29 +850,25 @@ private static void updateTableHeaderInfo( 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 inside table + // rows so they are nested two levels down. CollectionInfoCompat collectionInfo = collectionRoot.getCollectionInfo(); - searchTableHeaders(collectionRoot, collectionInfo, rowHeaders, columnHeaders, /* depth= */ 0); - } - - private static void searchTableHeaders( - AccessibilityNodeInfoCompat node, - CollectionInfoCompat collectionInfo, - SparseArray rowHeaders, - SparseArray columnHeaders, - int depth) { - if (node == null || depth > 4) { - return; - } - int numChildren = node.getChildCount(); + int numChildren = collectionRoot.getChildCount(); for (int i = 0; i < numChildren; ++i) { - AccessibilityNodeInfoCompat child = node.getChild(i); + AccessibilityNodeInfoCompat child = collectionRoot.getChild(i); if (child == null) { continue; } - boolean isCell = child.getCollectionItemInfo() != null; - boolean isHeader = updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders); - if (!isCell && !isHeader && depth < 4) { - searchTableHeaders(child, collectionInfo, rowHeaders, columnHeaders, depth + 1); + if (!updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders)) { + int numGrandchildren = child.getChildCount(); + for (int j = 0; j < numGrandchildren; ++j) { + AccessibilityNodeInfoCompat grandchild = child.getChild(j); + if (grandchild == null) { + continue; + } + updateSingleTableHeader(grandchild, collectionInfo, rowHeaders, columnHeaders); + } } } } @@ -910,14 +883,8 @@ private static boolean updateSingleTableHeader( } CharSequence headingName = getHeaderText(node); - if (TextUtils.isEmpty(headingName)) { - headingName = AccessibilityNodeInfoUtils.getGridColumnTitle(node); - } - if (TextUtils.isEmpty(headingName)) { - headingName = AccessibilityNodeInfoUtils.getGridRowTitle(node); - } CollectionItemInfoCompat itemInfo = node.getCollectionItemInfo(); - if (itemInfo != null && !TextUtils.isEmpty(headingName)) { + if (itemInfo != null && headingName != null) { @TableHeadingType int headingType = getTableHeadingType(node, itemInfo, collectionInfo); if ((headingType & TYPE_ROW) != 0) { rowHeaders.put(itemInfo.getRowIndex(), headingName); @@ -982,7 +949,7 @@ private static int getTableHeadingType( @NonNull AccessibilityNodeInfoCompat node, @NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { - if (AccessibilityNodeInfoUtils.isHeading(node) || item.isHeading()) { + if (AccessibilityNodeInfoUtils.isHeading(node)) { if (item.getRowSpan() == 1 && item.getColumnSpan() == 1) { if (getRowIndex(item, collection) == 0 && collection.getColumnCount() > 1) { return TYPE_COLUMN;