Populates collection info count for A11y toggle feature pages.
This helps an accessibility service like TalkBack inform the user that
there are items that are skipped when navigating the list because they
are unimportant to accessibility.
Bug: 318607873
Test: atest AccessibilityFragmentUtilsTest
Test: atest ToggleScreenMagnificationPreferenceFragmentTest
Test: Enable TalkBack that supports the collection info count feature,
open any of the pages from the bug, observe the item count and
(un)important count are correct.
Flag: com.android.settings.accessibility.toggle_feature_fragment_collection_info
Change-Id: If64c89f2eb2f8301076baa79b9530124c850d2fc
diff --git a/aconfig/accessibility/accessibility_flags.aconfig b/aconfig/accessibility/accessibility_flags.aconfig
index 5c81cc9..1871172 100644
--- a/aconfig/accessibility/accessibility_flags.aconfig
+++ b/aconfig/accessibility/accessibility_flags.aconfig
@@ -38,6 +38,13 @@
}
flag {
+ name: "enable_color_contrast_control"
+ namespace: "accessibility"
+ description: "Allows users to control color contrast in the Accessibility settings page."
+ bug: "246577325"
+}
+
+flag {
name: "enable_hearing_aid_preset_control"
namespace: "accessibility"
description: "Allows users to control hearing aid preset in the Bluetooth device details page."
@@ -89,8 +96,11 @@
}
flag {
- name: "enable_color_contrast_control"
+ name: "toggle_feature_fragment_collection_info"
namespace: "accessibility"
- description: "Allows users to control color contrast in the Accessibility settings page."
- bug: "246577325"
+ description: "Provides custom CollectionInfo for ToggleFeaturePreferenceFragment"
+ bug: "318607873"
+ metadata {
+ purpose: PURPOSE_BUGFIX
+ }
}
diff --git a/src/com/android/settings/accessibility/AccessibilityFragmentUtils.java b/src/com/android/settings/accessibility/AccessibilityFragmentUtils.java
new file mode 100644
index 0000000..34e17c0
--- /dev/null
+++ b/src/com/android/settings/accessibility/AccessibilityFragmentUtils.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.accessibility;
+
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.view.AccessibilityDelegateCompat;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroupAdapter;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate;
+
+import com.android.settingslib.widget.IllustrationPreference;
+
+/** Utilities for {@code Settings > Accessibility} fragments. */
+public class AccessibilityFragmentUtils {
+ // TODO: b/350782252 - Replace with an official library-provided solution when available.
+ /**
+ * Modifies the existing {@link RecyclerViewAccessibilityDelegate} of the provided
+ * {@link RecyclerView} for this fragment to report the number of visible and important
+ * items on this page via the RecyclerView's {@link AccessibilityNodeInfo}.
+ *
+ * <p><strong>Note:</strong> This is special-cased to the structure of these fragments:
+ * one column, N rows (one per preference, including category titles and header+footer
+ * preferences), <=N 'important' rows (image prefs without content descriptions). This
+ * is not intended for use with generic {@link RecyclerView}s.
+ */
+ public static RecyclerView addCollectionInfoToAccessibilityDelegate(RecyclerView recyclerView) {
+ if (!Flags.toggleFeatureFragmentCollectionInfo()) {
+ return recyclerView;
+ }
+ final RecyclerViewAccessibilityDelegate delegate =
+ recyclerView.getCompatAccessibilityDelegate();
+ if (delegate == null) {
+ // No delegate, so do nothing. This should not occur for real RecyclerViews.
+ return recyclerView;
+ }
+ recyclerView.setAccessibilityDelegateCompat(
+ new RvAccessibilityDelegateWrapper(recyclerView, delegate) {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(@NonNull View host,
+ @NonNull AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ if (!(recyclerView.getAdapter()
+ instanceof final PreferenceGroupAdapter preferenceGroupAdapter)) {
+ return;
+ }
+ final int visibleCount = preferenceGroupAdapter.getItemCount();
+ int importantCount = 0;
+ for (int i = 0; i < visibleCount; i++) {
+ if (isPreferenceImportantToA11y(preferenceGroupAdapter.getItem(i))) {
+ importantCount++;
+ }
+ }
+ info.unwrap().setCollectionInfo(
+ new AccessibilityNodeInfo.CollectionInfo(
+ /*rowCount=*/visibleCount,
+ /*columnCount=*/1,
+ /*hierarchical=*/false,
+ AccessibilityNodeInfo.CollectionInfo.SELECTION_MODE_SINGLE,
+ /*itemCount=*/visibleCount,
+ /*importantForAccessibilityItemCount=*/importantCount));
+ }
+ });
+ return recyclerView;
+ }
+
+ /**
+ * Returns whether the preference will be marked as important to accessibility for the sake
+ * of calculating {@link AccessibilityNodeInfo.CollectionInfo} counts.
+ *
+ * <p>The accessibility service itself knows this information for an individual preference
+ * on the screen, but it expects the preference's {@link RecyclerView} to also provide the
+ * same information for its entire set of adapter items.
+ */
+ @VisibleForTesting
+ static boolean isPreferenceImportantToA11y(Preference pref) {
+ if ((pref instanceof IllustrationPreference illustrationPref
+ && TextUtils.isEmpty(illustrationPref.getContentDescription()))
+ || pref instanceof PaletteListPreference) {
+ // Illustration preference that is visible but unannounced by accessibility services.
+ return false;
+ }
+ // All other preferences from the PreferenceGroupAdapter are important.
+ return true;
+ }
+
+ /**
+ * Wrapper around a {@link RecyclerViewAccessibilityDelegate} that allows customizing
+ * a subset of methods and while also deferring to the original. All overridden methods
+ * in instantiations of this class should call {@code super}.
+ */
+ private static class RvAccessibilityDelegateWrapper extends RecyclerViewAccessibilityDelegate {
+ private final RecyclerViewAccessibilityDelegate mOriginal;
+
+ RvAccessibilityDelegateWrapper(RecyclerView recyclerView,
+ RecyclerViewAccessibilityDelegate original) {
+ super(recyclerView);
+ mOriginal = original;
+ }
+
+ @Override
+ public boolean performAccessibilityAction(@NonNull View host, int action, Bundle args) {
+ return mOriginal.performAccessibilityAction(host, action, args);
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(@NonNull View host,
+ @NonNull AccessibilityNodeInfoCompat info) {
+ mOriginal.onInitializeAccessibilityNodeInfo(host, info);
+ }
+
+ @Override
+ public void onInitializeAccessibilityEvent(@NonNull View host,
+ @NonNull AccessibilityEvent event) {
+ mOriginal.onInitializeAccessibilityEvent(host, event);
+ }
+
+ @Override
+ @NonNull
+ public AccessibilityDelegateCompat getItemDelegate() {
+ if (mOriginal == null) {
+ // Needed for super constructor which calls getItemDelegate before mOriginal is
+ // defined, but unused by actual clients of this RecyclerViewAccessibilityDelegate
+ // which invoke getItemDelegate() after the constructor finishes.
+ return new ItemDelegate(this);
+ }
+ return mOriginal.getItemDelegate();
+ }
+ }
+}
diff --git a/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java b/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java
index 0ac29bc..9c61e5c 100644
--- a/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java
+++ b/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java
@@ -56,6 +56,7 @@
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceScreen;
+import androidx.recyclerview.widget.RecyclerView;
import com.android.internal.accessibility.common.ShortcutConstants;
import com.android.internal.accessibility.common.ShortcutConstants.UserShortcutType;
@@ -871,4 +872,12 @@
return PreferredShortcuts.retrieveUserShortcutType(
getPrefContext(), mComponentName.flattenToString(), getDefaultShortcutTypes());
}
+
+ @Override
+ public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent,
+ Bundle savedInstanceState) {
+ RecyclerView recyclerView =
+ super.onCreateRecyclerView(inflater, parent, savedInstanceState);
+ return AccessibilityFragmentUtils.addCollectionInfoToAccessibilityDelegate(recyclerView);
+ }
}
diff --git a/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentForSetupWizard.java b/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentForSetupWizard.java
index 97405d2..52d75c1 100644
--- a/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentForSetupWizard.java
+++ b/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentForSetupWizard.java
@@ -79,7 +79,8 @@
Bundle savedInstanceState) {
if (parent instanceof GlifPreferenceLayout) {
final GlifPreferenceLayout layout = (GlifPreferenceLayout) parent;
- return layout.onCreateRecyclerView(inflater, parent, savedInstanceState);
+ return AccessibilityFragmentUtils.addCollectionInfoToAccessibilityDelegate(
+ layout.onCreateRecyclerView(inflater, parent, savedInstanceState));
}
return super.onCreateRecyclerView(inflater, parent, savedInstanceState);
}
diff --git a/src/com/android/settings/accessibility/ToggleScreenReaderPreferenceFragmentForSetupWizard.java b/src/com/android/settings/accessibility/ToggleScreenReaderPreferenceFragmentForSetupWizard.java
index 4309b1d..10813a7 100644
--- a/src/com/android/settings/accessibility/ToggleScreenReaderPreferenceFragmentForSetupWizard.java
+++ b/src/com/android/settings/accessibility/ToggleScreenReaderPreferenceFragmentForSetupWizard.java
@@ -68,7 +68,8 @@
Bundle savedInstanceState) {
if (parent instanceof GlifPreferenceLayout) {
final GlifPreferenceLayout layout = (GlifPreferenceLayout) parent;
- return layout.onCreateRecyclerView(inflater, parent, savedInstanceState);
+ return AccessibilityFragmentUtils.addCollectionInfoToAccessibilityDelegate(
+ layout.onCreateRecyclerView(inflater, parent, savedInstanceState));
}
return super.onCreateRecyclerView(inflater, parent, savedInstanceState);
}
diff --git a/src/com/android/settings/accessibility/ToggleSelectToSpeakPreferenceFragmentForSetupWizard.java b/src/com/android/settings/accessibility/ToggleSelectToSpeakPreferenceFragmentForSetupWizard.java
index 8d26785..10796b5 100644
--- a/src/com/android/settings/accessibility/ToggleSelectToSpeakPreferenceFragmentForSetupWizard.java
+++ b/src/com/android/settings/accessibility/ToggleSelectToSpeakPreferenceFragmentForSetupWizard.java
@@ -68,7 +68,8 @@
Bundle savedInstanceState) {
if (parent instanceof GlifPreferenceLayout) {
final GlifPreferenceLayout layout = (GlifPreferenceLayout) parent;
- return layout.onCreateRecyclerView(inflater, parent, savedInstanceState);
+ return AccessibilityFragmentUtils.addCollectionInfoToAccessibilityDelegate(
+ layout.onCreateRecyclerView(inflater, parent, savedInstanceState));
}
return super.onCreateRecyclerView(inflater, parent, savedInstanceState);
}
diff --git a/src/com/android/settings/gestures/OneHandedSettings.java b/src/com/android/settings/gestures/OneHandedSettings.java
index c84b9ea..0a1ab64 100644
--- a/src/com/android/settings/gestures/OneHandedSettings.java
+++ b/src/com/android/settings/gestures/OneHandedSettings.java
@@ -23,9 +23,14 @@
import android.os.Bundle;
import android.os.UserHandle;
import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.recyclerview.widget.RecyclerView;
import com.android.internal.accessibility.AccessibilityShortcutController;
import com.android.settings.R;
+import com.android.settings.accessibility.AccessibilityFragmentUtils;
import com.android.settings.accessibility.AccessibilityShortcutPreferenceFragment;
import com.android.settings.accessibility.AccessibilityUtil.QuickSettingsTooltipType;
import com.android.settings.search.BaseSearchIndexProvider;
@@ -176,4 +181,12 @@
return OneHandedSettingsUtils.isSupportOneHandedMode();
}
};
+
+ @Override
+ public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent,
+ Bundle savedInstanceState) {
+ RecyclerView recyclerView =
+ super.onCreateRecyclerView(inflater, parent, savedInstanceState);
+ return AccessibilityFragmentUtils.addCollectionInfoToAccessibilityDelegate(recyclerView);
+ }
}
diff --git a/tests/robotests/src/com/android/settings/accessibility/AccessibilityFragmentUtilsTest.java b/tests/robotests/src/com/android/settings/accessibility/AccessibilityFragmentUtilsTest.java
new file mode 100644
index 0000000..cd4ee89
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/accessibility/AccessibilityFragmentUtilsTest.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.accessibility;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+
+import androidx.preference.Preference;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settingslib.widget.IllustrationPreference;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link AccessibilityFragmentUtils} */
+@RunWith(RobolectricTestRunner.class)
+public class AccessibilityFragmentUtilsTest {
+
+ private final Context mContext = ApplicationProvider.getApplicationContext();
+
+ @Test
+ public void isPreferenceImportantToA11y_basicPreference_isImportant() {
+ final Preference pref = new ShortcutPreference(mContext, /* attrs= */ null);
+
+ assertThat(AccessibilityFragmentUtils.isPreferenceImportantToA11y(pref)).isTrue();
+ }
+
+ @Test
+ public void isPreferenceImportantToA11y_illustrationPreference_hasContentDesc_isImportant() {
+ final IllustrationPreference pref =
+ new IllustrationPreference(mContext, /* attrs= */ null);
+ pref.setContentDescription("content desc");
+
+ assertThat(AccessibilityFragmentUtils.isPreferenceImportantToA11y(pref)).isTrue();
+ }
+
+ @Test
+ public void isPreferenceImportantToA11y_illustrationPreference_noContentDesc_notImportant() {
+ final IllustrationPreference pref =
+ new IllustrationPreference(mContext, /* attrs= */ null);
+ pref.setContentDescription(null);
+
+ assertThat(AccessibilityFragmentUtils.isPreferenceImportantToA11y(pref)).isFalse();
+ }
+
+ @Test
+ public void isPreferenceImportantToA11y_paletteListPreference_notImportant() {
+ final PaletteListPreference pref =
+ new PaletteListPreference(mContext, /* attrs= */ null);
+
+ assertThat(AccessibilityFragmentUtils.isPreferenceImportantToA11y(pref)).isFalse();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java b/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java
index 22bb266..038672f 100644
--- a/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java
+++ b/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java
@@ -53,9 +53,12 @@
import android.provider.DeviceConfig;
import android.provider.Settings;
import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityNodeInfo;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.preference.Preference;
import androidx.preference.TwoStatePreference;
+import androidx.recyclerview.widget.RecyclerView;
import androidx.test.core.app.ApplicationProvider;
import com.android.server.accessibility.Flags;
@@ -1000,6 +1003,28 @@
assertThat(summary).isEqualTo(expected);
}
+ @Test
+ @EnableFlags(
+ com.android.settings.accessibility.Flags.FLAG_TOGGLE_FEATURE_FRAGMENT_COLLECTION_INFO)
+ public void fragmentRecyclerView_getCollectionInfo_hasCorrectCounts() {
+ ToggleScreenMagnificationPreferenceFragment fragment =
+ mFragController.create(R.id.main_content, /* bundle= */
+ null).start().resume().get();
+ RecyclerView rv = fragment.getListView();
+
+ AccessibilityNodeInfoCompat node = AccessibilityNodeInfoCompat.obtain();
+ rv.getCompatAccessibilityDelegate().onInitializeAccessibilityNodeInfo(rv, node);
+ AccessibilityNodeInfo.CollectionInfo collectionInfo = node.unwrap().getCollectionInfo();
+
+ // Asserting against specific item counts will be brittle to changes to the preferences
+ // included on this page, so instead just check some properties of these counts.
+ assertThat(collectionInfo.getColumnCount()).isEqualTo(1);
+ assertThat(collectionInfo.getRowCount()).isEqualTo(collectionInfo.getItemCount());
+ assertThat(collectionInfo.getItemCount())
+ // One unimportant item: the illustration preference
+ .isEqualTo(collectionInfo.getImportantForAccessibilityItemCount() + 1);
+ }
+
private void putStringIntoSettings(String key, String componentName) {
Settings.Secure.putString(mContext.getContentResolver(), key, componentName);
}