From 1890c16f90ee7731d8493acebc10fb6c464fcfb2 Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Mon, 26 Aug 2024 19:45:27 +0000 Subject: [PATCH] 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 --- .../accessibility/accessibility_flags.aconfig | 16 +- .../AccessibilityFragmentUtils.java | 152 ++++++++++++++++++ .../ToggleFeaturePreferenceFragment.java | 9 ++ ...ationPreferenceFragmentForSetupWizard.java | 3 +- ...eaderPreferenceFragmentForSetupWizard.java | 3 +- ...SpeakPreferenceFragmentForSetupWizard.java | 3 +- .../settings/gestures/OneHandedSettings.java | 13 ++ .../AccessibilityFragmentUtilsTest.java | 70 ++++++++ ...enMagnificationPreferenceFragmentTest.java | 25 +++ 9 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 src/com/android/settings/accessibility/AccessibilityFragmentUtils.java create mode 100644 tests/robotests/src/com/android/settings/accessibility/AccessibilityFragmentUtilsTest.java diff --git a/aconfig/accessibility/accessibility_flags.aconfig b/aconfig/accessibility/accessibility_flags.aconfig index 5c81cc9a671..1871172fc5a 100644 --- a/aconfig/accessibility/accessibility_flags.aconfig +++ b/aconfig/accessibility/accessibility_flags.aconfig @@ -37,6 +37,13 @@ flag { bug: "300302098" } +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" @@ -89,8 +96,11 @@ flag { } 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 00000000000..34e17c01335 --- /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}. + * + *

Note: 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. + * + *

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 0ac29bc6ba5..9c61e5c3305 100644 --- a/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java +++ b/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java @@ -56,6 +56,7 @@ import androidx.annotation.VisibleForTesting; 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 @@ public abstract class ToggleFeaturePreferenceFragment extends DashboardFragment 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 97405d24e9f..52d75c19ed4 100644 --- a/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentForSetupWizard.java +++ b/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentForSetupWizard.java @@ -79,7 +79,8 @@ public class ToggleScreenMagnificationPreferenceFragmentForSetupWizard 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 4309b1d9038..10813a7e262 100644 --- a/src/com/android/settings/accessibility/ToggleScreenReaderPreferenceFragmentForSetupWizard.java +++ b/src/com/android/settings/accessibility/ToggleScreenReaderPreferenceFragmentForSetupWizard.java @@ -68,7 +68,8 @@ public class ToggleScreenReaderPreferenceFragmentForSetupWizard 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 8d26785d021..10796b5d218 100644 --- a/src/com/android/settings/accessibility/ToggleSelectToSpeakPreferenceFragmentForSetupWizard.java +++ b/src/com/android/settings/accessibility/ToggleSelectToSpeakPreferenceFragmentForSetupWizard.java @@ -68,7 +68,8 @@ public class ToggleSelectToSpeakPreferenceFragmentForSetupWizard 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 c84b9ea6934..0a1ab64360c 100644 --- a/src/com/android/settings/gestures/OneHandedSettings.java +++ b/src/com/android/settings/gestures/OneHandedSettings.java @@ -23,9 +23,14 @@ import android.content.Context; 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 @@ public class OneHandedSettings extends AccessibilityShortcutPreferenceFragment { 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 00000000000..cd4ee89aaaf --- /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 22bb2669bb5..038672fc198 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.platform.test.flag.junit.SetFlagsRule; 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 @@ public class ToggleScreenMagnificationPreferenceFragmentTest { 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); }