Merge "Populates collection info count for A11y toggle feature pages." into main
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);
     }