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); }