diff --git a/differences.md b/differences.md index 9b0d751e3..e808f2023 100644 --- a/differences.md +++ b/differences.md @@ -144,6 +144,16 @@ TalkBack echoed a word such as "don't" as two words. Backtalk echoes it as one w When you turn Backtalk off, it says "Backtalk off" at the accessibility volume, using your device's real volume levels. TalkBack only approximated that volume. +### Table column headers + +In **Verbosity**, you can choose whether Backtalk reads table column headers before or after the cell's data, or leaves them out: + +* **After cell data**, the default, keeps TalkBack's order: the cell's contents, then the row and column headers or numbers. +* **Before cell data** speaks the row and column headers or numbers before the cell's contents. +* **Do not read** leaves column headers out, and reads column numbers only when row and column numbers are on. + +Under preset settings in **Verbosity**, you can also turn off **Speak row and column numbers** to hear only named headers without row and column coordinates. The setting is on by default. + ## Notifications ### Speak notifications setting 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..8d897225f 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 @@ -206,6 +206,24 @@ public static CharSequence getCollectionItemTransitionDescription( @Nullable AccessibilityNodeInfoCompat focusedNode, CollectionState collectionState, Context context) { + return getCollectionItemTransitionDescription( + focusedNode, + collectionState, + context, + GlobalVariables.TABLE_HEADERS_AFTER, + /* speakTableRowColumnNumbers= */ true); + } + + /** + * Returns the collection item description when the collection is transitioned, with table header + * and coordinate preferences applied. + */ + public static CharSequence getCollectionItemTransitionDescription( + @Nullable AccessibilityNodeInfoCompat focusedNode, + CollectionState collectionState, + Context context, + String tableColumnHeaders, + boolean speakTableRowColumnNumbers) { boolean isRowTransition = getCollectionIsRowTransition(collectionState); boolean isColumnTransition = getCollectionIsColumnTransition(collectionState); if (isRowTransition || isColumnTransition) { @@ -229,16 +247,28 @@ public static CharSequence getCollectionItemTransitionDescription( if (isRowTransition && tableItemRowIndex != -1 && headingType != CollectionState.TYPE_ROW) { - joinList.add( - getCollectionTableItemRowName(collectionState, tableItemRowIndex, context)); + CharSequence rowDesc = + getCollectionTableItemRowName( + collectionState, tableItemRowIndex, context, speakTableRowColumnNumbers); + if (!TextUtils.isEmpty(rowDesc)) { + joinList.add(rowDesc); + } } int tableItemColumnIndex = getCollectionTableItemColumnIndex(collectionState); if (isColumnTransition && tableItemColumnIndex != -1 && headingType != CollectionState.TYPE_COLUMN) { - joinList.add( - getCollectionTableItemColumnName(collectionState, tableItemColumnIndex, context)); + CharSequence colDesc = + getCollectionTableItemColumnName( + collectionState, + tableItemColumnIndex, + context, + tableColumnHeaders, + speakTableRowColumnNumbers); + if (!TextUtils.isEmpty(colDesc)) { + joinList.add(colDesc); + } } return CompositorUtils.joinCharSequences(joinList, CompositorUtils.getSeparator(), true); } @@ -321,32 +351,58 @@ private static CharSequence getCollectionTableItemRowName(CollectionState collec } private static CharSequence getCollectionTableItemRowName( - CollectionState collectionState, int tableItemRowIndex, Context context) { + CollectionState collectionState, + int tableItemRowIndex, + Context context, + boolean speakTableRowColumnNumbers) { CharSequence tableItemRowName = getCollectionTableItemRowName(collectionState); if (!TextUtils.isEmpty(tableItemRowName)) { return tableItemRowName; - } else { + } else if (speakTableRowColumnNumbers) { int newRowIndex = tableItemRowIndex + 1; return context.getString(R.string.row_index_template, newRowIndex); + } else { + return ""; } } + private static CharSequence getCollectionTableItemRowName( + CollectionState collectionState, int tableItemRowIndex, Context context) { + return getCollectionTableItemRowName(collectionState, tableItemRowIndex, context, true); + } + private static CharSequence getCollectionTableItemColumnName(CollectionState collectionState) { - CollectionState.TableItemState itemState = collectionState.getTableItemState(); - return itemState != null ? itemState.getColumnName() : ""; + return (collectionState != null && collectionState.getTableItemState() != null) + ? collectionState.getTableItemState().getColumnName() + : ""; } private static CharSequence getCollectionTableItemColumnName( - CollectionState collectionState, int tableItemColumnIndex, Context context) { - CharSequence tableItemColumnName = getCollectionTableItemColumnName(collectionState); + CollectionState collectionState, + int tableItemColumnIndex, + Context context, + String tableColumnHeaders, + boolean speakTableRowColumnNumbers) { + CharSequence tableItemColumnName = + GlobalVariables.TABLE_HEADERS_OFF.equals(tableColumnHeaders) + ? "" + : getCollectionTableItemColumnName(collectionState); if (!TextUtils.isEmpty(tableItemColumnName)) { return tableItemColumnName; - } else { + } else if (speakTableRowColumnNumbers) { int newColumnIndex = tableItemColumnIndex + 1; return context.getString(R.string.column_index_template, newColumnIndex); + } else { + return ""; } } + private static CharSequence getCollectionTableItemColumnName( + CollectionState collectionState, int tableItemColumnIndex, Context context) { + return getCollectionTableItemColumnName( + collectionState, tableItemColumnIndex, context, GlobalVariables.TABLE_HEADERS_AFTER, true); + } + private static CharSequence getCollectionTableItemRoleDescription( CollectionState collectionState) { CollectionState.TableItemState itemState = collectionState.getTableItemState(); 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..36ddd2b38 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; } @@ -901,7 +925,11 @@ public CharSequence getCollectionTransitionDescription() { public CharSequence getCollectionItemTransitionDescription( @Nullable AccessibilityNodeInfoCompat focusedNode) { return CollectionStateFeedbackUtils.getCollectionItemTransitionDescription( - focusedNode, collectionState, mContext); + focusedNode, collectionState, mContext, tableColumnHeaders, speakTableRowColumnNumbers); + } + + public int getCollectionRole() { + return collectionState.getCollectionRole(); } /** Returns if the reading menu has actions settings. */ 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..f563f241a 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,21 +270,14 @@ 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)); } } - // Add phonetic spelling if necessary. - Optional phoneticExample = - processorPhoneticLetters.getPhoneticLetterForKeyboardFocusEvent(event); - phoneticExample.ifPresent(outputJoinList::add); - 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 @@ -291,24 +285,50 @@ private static CharSequence viewAccessibilityFocusedDescription( .append(String.format(", speakRoles=%s", speakRoles)); CharSequence collectionItemTransition = speakCollectionInfo ? globalVariables.getCollectionItemTransitionDescription(node) : ""; - if (!TextUtils.isEmpty(collectionItemTransition)) { + + boolean isTableGrid = (globalVariables.getCollectionRole() == Role.ROLE_GRID); + boolean headersBefore = + isTableGrid + && GlobalVariables.TABLE_HEADERS_BEFORE.equals( + globalVariables.getTableColumnHeaders()); + + if (headersBefore && !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 (!TextUtils.isEmpty(contentDescription)) { + outputJoinList.add(contentDescription); + } + + // Add phonetic spelling if necessary. + Optional phoneticExample = + processorPhoneticLetters.getPhoneticLetterForKeyboardFocusEvent(event); + phoneticExample.ifPresent(outputJoinList::add); + logString.append(String.format("\n phoneticExample={%s}", phoneticExample)); + + // Prepare Collection item transition state or Node role/heading description for feedback. + if (!headersBefore) { + 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/preference/base/VerbosityPrefFragment.java b/talkback/src/main/java/com/google/android/accessibility/talkback/preference/base/VerbosityPrefFragment.java index ceaef77b0..4c7d5b8c4 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/preference/base/VerbosityPrefFragment.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/preference/base/VerbosityPrefFragment.java @@ -151,6 +151,9 @@ private void buildMap() { .put( getString(R.string.pref_speak_container_element_positions_key), getResources().getBoolean(R.bool.pref_speak_container_element_positions_default)) + .put( + getString(R.string.pref_table_speak_row_column_numbers_key), + getResources().getBoolean(R.bool.pref_table_speak_row_column_numbers_default)) .put( getString(R.string.pref_verbose_scroll_announcement_key), getResources().getBoolean(R.bool.pref_verbose_scroll_announcement_default)) 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..8263a4084 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/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 1d7f69314..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/monitor/CollectionState.java b/utils/src/main/java/com/google/android/accessibility/utils/monitor/CollectionState.java index 2d6b2fdea..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 @@ -616,6 +616,29 @@ 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(); @@ -850,25 +873,29 @@ 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 isCell = child.getCollectionItemInfo() != null; + boolean isHeader = updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders); + if (!isCell && !isHeader && depth < 4) { + searchTableHeaders(child, collectionInfo, rowHeaders, columnHeaders, depth + 1); } } } @@ -883,8 +910,14 @@ 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 && headingName != null) { + if (itemInfo != null && !TextUtils.isEmpty(headingName)) { @TableHeadingType int headingType = getTableHeadingType(node, itemInfo, collectionInfo); if ((headingType & TYPE_ROW) != 0) { rowHeaders.put(itemInfo.getRowIndex(), headingName); @@ -949,7 +982,7 @@ private static int getTableHeadingType( @NonNull AccessibilityNodeInfoCompat node, @NonNull CollectionItemInfoCompat item, @NonNull CollectionInfoCompat collection) { - if (AccessibilityNodeInfoUtils.isHeading(node)) { + if (AccessibilityNodeInfoUtils.isHeading(node) || item.isHeading()) { if (item.getRowSpan() == 1 && item.getColumnSpan() == 1) { if (getRowIndex(item, collection) == 0 && collection.getColumnCount() > 1) { return TYPE_COLUMN;