Support grouping in the New App List

By adding getGroupTitle() to AppListModel.

Default no grouping.

Note: App items should be sorted by group in the getComparator() first,
the new getGroupTitle() will not change the list order.

Bug: 259206313
Test: Unit test
Change-Id: I96fe8615c1f71066288e01d95805a85d5caaff13
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListModel.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListModel.kt
index 373b57f..a7122d0 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListModel.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListModel.kt
@@ -54,6 +54,14 @@
     )
 
     /**
+     * Gets the group title of this item.
+     *
+     * Note: Items should be sorted by group in [getComparator] first, this [getGroupTitle] will not
+     * change the list order.
+     */
+    fun getGroupTitle(option: Int, record: T): String? = null
+
+    /**
      * Gets the summary for the given app record.
      *
      * @return null if no summary should be displayed.
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt
index 7f5fe9f..681eb1c 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt
@@ -34,9 +34,11 @@
 import com.android.settingslib.spa.framework.compose.TimeMeasurer.Companion.rememberTimeMeasurer
 import com.android.settingslib.spa.framework.compose.rememberLazyListStateAndHideKeyboardWhenStartScroll
 import com.android.settingslib.spa.framework.compose.toState
+import com.android.settingslib.spa.widget.ui.CategoryTitle
 import com.android.settingslib.spa.widget.ui.PlaceholderTitle
 import com.android.settingslib.spaprivileged.R
 import com.android.settingslib.spaprivileged.framework.compose.DisposableBroadcastReceiverAsUser
+import com.android.settingslib.spaprivileged.model.app.AppEntry
 import com.android.settingslib.spaprivileged.model.app.AppListConfig
 import com.android.settingslib.spaprivileged.model.app.AppListData
 import com.android.settingslib.spaprivileged.model.app.AppListModel
@@ -100,17 +102,28 @@
             }
 
             items(count = list.size, key = { option to list[it].record.app.packageName }) {
+                remember(list) { listModel.getGroupTitleIfFirst(option, list, it) }
+                    ?.let { group -> CategoryTitle(title = group) }
+
                 val appEntry = list[it]
                 val summary = listModel.getSummary(option, appEntry.record) ?: "".toState()
-                val itemModel = remember(appEntry) {
+                appItem(remember(appEntry) {
                     AppListItemModel(appEntry.record, appEntry.label, summary)
-                }
-                appItem(itemModel)
+                })
             }
         }
     }
 }
 
+/** Returns group title if this is the first item of the group. */
+private fun <T : AppRecord> AppListModel<T>.getGroupTitleIfFirst(
+    option: Int,
+    list: List<AppEntry<T>>,
+    index: Int,
+): String? = getGroupTitle(option, list[index].record)?.takeIf {
+    index == 0 || it != getGroupTitle(option, list[index - 1].record)
+}
+
 @Composable
 private fun <T : AppRecord> loadAppListData(
     config: AppListConfig,
diff --git a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppListTest.kt b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppListTest.kt
index 80c4eac..9f20c78 100644
--- a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppListTest.kt
+++ b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppListTest.kt
@@ -58,26 +58,43 @@
 
     @Test
     fun couldShowAppItem() {
-        setContent(appEntries = listOf(APP_ENTRY))
+        setContent(appEntries = listOf(APP_ENTRY_A))
 
-        composeTestRule.onNodeWithText(APP_ENTRY.label).assertIsDisplayed()
+        composeTestRule.onNodeWithText(APP_ENTRY_A.label).assertIsDisplayed()
     }
 
     @Test
     fun couldShowHeader() {
-        setContent(header = { Text(HEADER) }, appEntries = listOf(APP_ENTRY))
+        setContent(appEntries = listOf(APP_ENTRY_A), header = { Text(HEADER) })
 
         composeTestRule.onNodeWithText(HEADER).assertIsDisplayed()
     }
 
+    @Test
+    fun whenNotGrouped_groupTitleDoesNotExist() {
+        setContent(appEntries = listOf(APP_ENTRY_A, APP_ENTRY_B), enableGrouping = false)
+
+        composeTestRule.onNodeWithText(GROUP_A).assertDoesNotExist()
+        composeTestRule.onNodeWithText(GROUP_B).assertDoesNotExist()
+    }
+
+    @Test
+    fun whenGrouped_groupTitleDisplayed() {
+        setContent(appEntries = listOf(APP_ENTRY_A, APP_ENTRY_B), enableGrouping = true)
+
+        composeTestRule.onNodeWithText(GROUP_A).assertIsDisplayed()
+        composeTestRule.onNodeWithText(GROUP_B).assertIsDisplayed()
+    }
+
     private fun setContent(
-        header: @Composable () -> Unit = {},
         appEntries: List<AppEntry<TestAppRecord>>,
+        header: @Composable () -> Unit = {},
+        enableGrouping: Boolean = false,
     ) {
         composeTestRule.setContent {
             AppList(
                 config = AppListConfig(userId = USER_ID, showInstantApps = false),
-                listModel = TestAppListModel(),
+                listModel = TestAppListModel(enableGrouping),
                 state = AppListState(
                     showSystem = false.toState(),
                     option = 0.toState(),
@@ -96,17 +113,37 @@
     private companion object {
         const val USER_ID = 0
         const val HEADER = "Header"
-        val APP_ENTRY = AppEntry(
-            record = TestAppRecord(ApplicationInfo()),
-            label = "AAA",
+        const val GROUP_A = "Group A"
+        const val GROUP_B = "Group B"
+        val APP_ENTRY_A = AppEntry(
+            record = TestAppRecord(
+                app = ApplicationInfo().apply {
+                    packageName = "package.name.a"
+                },
+                group = GROUP_A,
+            ),
+            label = "Label A",
+            labelCollationKey = CollationKey("", byteArrayOf()),
+        )
+        val APP_ENTRY_B = AppEntry(
+            record = TestAppRecord(
+                app = ApplicationInfo().apply {
+                    packageName = "package.name.b"
+                },
+                group = GROUP_B,
+            ),
+            label = "Label B",
             labelCollationKey = CollationKey("", byteArrayOf()),
         )
     }
 }
 
-private data class TestAppRecord(override val app: ApplicationInfo) : AppRecord
+private data class TestAppRecord(
+    override val app: ApplicationInfo,
+    val group: String? = null,
+) : AppRecord
 
-private class TestAppListModel : AppListModel<TestAppRecord> {
+private class TestAppListModel(val enableGrouping: Boolean) : AppListModel<TestAppRecord> {
     override fun transform(userIdFlow: Flow<Int>, appListFlow: Flow<List<ApplicationInfo>>) =
         appListFlow.asyncMapItem { TestAppRecord(it) }
 
@@ -118,4 +155,7 @@
         option: Int,
         recordListFlow: Flow<List<TestAppRecord>>,
     ) = recordListFlow
+
+    override fun getGroupTitle(option: Int, record: TestAppRecord) =
+        if (enableGrouping) record.group else null
 }