Create AppList for SpaPrivilegedLib

Bug: 235727273
Test: Manual with Test App
Change-Id: Ia5b179d311fe9172c705ad036588e172a5e2a5ca
diff --git a/packages/SettingsLib/SpaPrivileged/Android.bp b/packages/SettingsLib/SpaPrivileged/Android.bp
index ecbb219..a6469b5 100644
--- a/packages/SettingsLib/SpaPrivileged/Android.bp
+++ b/packages/SettingsLib/SpaPrivileged/Android.bp
@@ -28,5 +28,8 @@
         "SettingsLib",
         "androidx.compose.runtime_runtime",
     ],
-    kotlincflags: ["-Xjvm-default=all"],
+    kotlincflags: [
+        "-Xjvm-default=all",
+        "-Xopt-in=kotlin.RequiresOptIn",
+    ],
 }
diff --git a/packages/SettingsLib/SpaPrivileged/res/values/strings.xml b/packages/SettingsLib/SpaPrivileged/res/values/strings.xml
new file mode 100644
index 0000000..8f8dd2b
--- /dev/null
+++ b/packages/SettingsLib/SpaPrivileged/res/values/strings.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright (C) 2022 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.
+-->
+<resources>
+    <!-- [CHAR LIMIT=25] Text shown when there are no applications to display. -->
+    <string name="no_applications">No apps.</string>
+</resources>
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
new file mode 100644
index 0000000..2fa869c
--- /dev/null
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListModel.kt
@@ -0,0 +1,26 @@
+package com.android.settingslib.spaprivileged.model.app
+
+import android.content.pm.ApplicationInfo
+import android.icu.text.CollationKey
+import kotlinx.coroutines.flow.Flow
+
+data class AppEntry<T : AppRecord>(
+    val record: T,
+    val label: String,
+    val labelCollationKey: CollationKey,
+)
+
+interface AppListModel<T : AppRecord> {
+    fun getSpinnerOptions(): List<String> = emptyList()
+    fun transform(userIdFlow: Flow<Int>, appListFlow: Flow<List<ApplicationInfo>>): Flow<List<T>>
+    fun filter(userIdFlow: Flow<Int>, option: Int, recordListFlow: Flow<List<T>>): Flow<List<T>>
+
+    suspend fun onFirstLoaded(recordList: List<T>) {}
+    fun getComparator(option: Int): Comparator<AppEntry<T>> = compareBy(
+        { it.labelCollationKey },
+        { it.record.app.packageName },
+        { it.record.app.uid },
+    )
+
+    fun getSummary(option: Int, record: T): Flow<String>?
+}
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListViewModel.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListViewModel.kt
new file mode 100644
index 0000000..9265158
--- /dev/null
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListViewModel.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spaprivileged.model.app
+
+import android.app.Application
+import android.content.pm.ApplicationInfo
+import android.content.pm.UserInfo
+import android.icu.text.Collator
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.android.settingslib.spa.framework.util.StateFlowBridge
+import com.android.settingslib.spa.framework.util.asyncMapItem
+import com.android.settingslib.spa.framework.util.waitFirst
+import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.plus
+
+internal data class AppListData<T : AppRecord>(
+    val appEntries: List<AppEntry<T>>,
+    val option: Int,
+) {
+    fun filter(predicate: (AppEntry<T>) -> Boolean) =
+        AppListData(appEntries.filter(predicate), option)
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class AppListViewModel<T : AppRecord>(
+    application: Application,
+) : AndroidViewModel(application) {
+    val userInfo = StateFlowBridge<UserInfo>()
+    val listModel = StateFlowBridge<AppListModel<T>>()
+    val showSystem = StateFlowBridge<Boolean>()
+    val option = StateFlowBridge<Int>()
+    val searchQuery = StateFlowBridge<String>()
+
+    private val appsRepository = AppsRepository(application)
+    private val appRepository = AppRepositoryImpl(application)
+    private val collator = Collator.getInstance().freeze()
+    private val labelMap = ConcurrentHashMap<String, String>()
+    private val scope = viewModelScope + Dispatchers.Default
+
+    private val userIdFlow = userInfo.flow.map { it.id }
+
+    private val recordListFlow = listModel.flow
+        .flatMapLatest { it.transform(userIdFlow, appsRepository.loadApps(userInfo.flow)) }
+        .shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1)
+
+    private val systemFilteredFlow = appsRepository.showSystemPredicate(userIdFlow, showSystem.flow)
+        .combine(recordListFlow) { showAppPredicate, recordList ->
+            recordList.filter { showAppPredicate(it.app) }
+        }
+
+    val appListDataFlow = option.flow.flatMapLatest(::filterAndSort)
+        .combine(searchQuery.flow) { appListData, searchQuery ->
+            appListData.filter {
+                it.label.contains(other = searchQuery, ignoreCase = true)
+            }
+        }
+        .shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1)
+
+    init {
+        scheduleOnFirstLoaded()
+    }
+
+    private fun filterAndSort(option: Int) = listModel.flow.flatMapLatest { listModel ->
+        listModel.filter(userIdFlow, option, systemFilteredFlow)
+            .asyncMapItem { record ->
+                val label = getLabel(record.app)
+                AppEntry(
+                    record = record,
+                    label = label,
+                    labelCollationKey = collator.getCollationKey(label),
+                )
+            }
+            .map { appEntries ->
+                AppListData(
+                    appEntries = appEntries.sortedWith(listModel.getComparator(option)),
+                    option = option,
+                )
+            }
+    }
+
+    private fun scheduleOnFirstLoaded() {
+        recordListFlow
+            .waitFirst(appListDataFlow)
+            .combine(listModel.flow) { recordList, listModel ->
+                listModel.maybePreFetchLabels(recordList)
+                listModel.onFirstLoaded(recordList)
+            }
+            .launchIn(scope)
+    }
+
+    private fun AppListModel<T>.maybePreFetchLabels(recordList: List<T>) {
+        if (getSpinnerOptions().isNotEmpty()) {
+            for (record in recordList) {
+                getLabel(record.app)
+            }
+        }
+    }
+
+    private fun getLabel(app: ApplicationInfo) = labelMap.computeIfAbsent(app.packageName) {
+        appRepository.loadLabel(app)
+    }
+}
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppRepository.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppRepository.kt
index 7ffa938..34f12af 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppRepository.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppRepository.kt
@@ -31,6 +31,8 @@
 fun rememberAppRepository(): AppRepository = rememberContext(::AppRepositoryImpl)
 
 interface AppRepository {
+    fun loadLabel(app: ApplicationInfo): String
+
     @Composable
     fun produceLabel(app: ApplicationInfo): State<String>
 
@@ -38,9 +40,11 @@
     fun produceIcon(app: ApplicationInfo): State<Drawable?>
 }
 
-private class AppRepositoryImpl(private val context: Context) : AppRepository {
+internal class AppRepositoryImpl(private val context: Context) : AppRepository {
     private val packageManager = context.packageManager
 
+    override fun loadLabel(app: ApplicationInfo): String = app.loadLabel(packageManager).toString()
+
     @Composable
     override fun produceLabel(app: ApplicationInfo) = produceState(initialValue = "", app) {
         withContext(Dispatchers.Default) {
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppsRepository.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppsRepository.kt
new file mode 100644
index 0000000..6a64620
--- /dev/null
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppsRepository.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spaprivileged.model.app
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.content.pm.UserInfo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+
+class AppsRepository(context: Context) {
+    private val packageManager = context.packageManager
+
+    fun loadApps(userInfoFlow: Flow<UserInfo>): Flow<List<ApplicationInfo>> = userInfoFlow
+        .map { loadApps(it) }
+        .flowOn(Dispatchers.Default)
+
+    private suspend fun loadApps(userInfo: UserInfo): List<ApplicationInfo> {
+        return coroutineScope {
+            val hiddenSystemModulesDeferred = async {
+                packageManager.getInstalledModules(0)
+                    .filter { it.isHidden }
+                    .map { it.packageName }
+                    .toSet()
+            }
+            val flags = PackageManager.ApplicationInfoFlags.of(
+                ((if (userInfo.isAdmin) PackageManager.MATCH_ANY_USER else 0) or
+                    PackageManager.MATCH_DISABLED_COMPONENTS or
+                    PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS).toLong()
+            )
+            val installedApplicationsAsUser =
+                packageManager.getInstalledApplicationsAsUser(flags, userInfo.id)
+
+            val hiddenSystemModules = hiddenSystemModulesDeferred.await()
+            installedApplicationsAsUser.filter { app ->
+                app.isInAppList(hiddenSystemModules)
+            }
+        }
+    }
+
+    fun showSystemPredicate(
+        userIdFlow: Flow<Int>,
+        showSystemFlow: Flow<Boolean>,
+    ): Flow<(app: ApplicationInfo) -> Boolean> =
+        userIdFlow.combine(showSystemFlow) { userId, showSystem ->
+            showSystemPredicate(userId, showSystem)
+        }
+
+    private suspend fun showSystemPredicate(
+        userId: Int,
+        showSystem: Boolean,
+    ): (app: ApplicationInfo) -> Boolean {
+        if (showSystem) return { true }
+        val homeOrLauncherPackages = loadHomeOrLauncherPackages(userId)
+        return { app ->
+            app.isUpdatedSystemApp || !app.isSystemApp || app.packageName in homeOrLauncherPackages
+        }
+    }
+
+    private suspend fun loadHomeOrLauncherPackages(userId: Int): Set<String> {
+        val launchIntent = Intent(Intent.ACTION_MAIN, null).addCategory(Intent.CATEGORY_LAUNCHER)
+        // If we do not specify MATCH_DIRECT_BOOT_AWARE or MATCH_DIRECT_BOOT_UNAWARE, system will
+        // derive and update the flags according to the user's lock state. When the user is locked,
+        // components with ComponentInfo#directBootAware == false will be filtered. We should
+        // explicitly include both direct boot aware and unaware component here.
+        val flags = PackageManager.ResolveInfoFlags.of(
+            (PackageManager.MATCH_DISABLED_COMPONENTS or
+                PackageManager.MATCH_DIRECT_BOOT_AWARE or
+                PackageManager.MATCH_DIRECT_BOOT_UNAWARE).toLong()
+        )
+        return coroutineScope {
+            val launcherActivities = async {
+                packageManager.queryIntentActivitiesAsUser(launchIntent, flags, userId)
+            }
+            val homeActivities = ArrayList<ResolveInfo>()
+            packageManager.getHomeActivities(homeActivities)
+            (launcherActivities.await() + homeActivities)
+                .map { it.activityInfo.packageName }
+                .toSet()
+        }
+    }
+
+    companion object {
+        private fun ApplicationInfo.isInAppList(hiddenSystemModules: Set<String>) =
+            when {
+                packageName in hiddenSystemModules -> false
+                enabled -> true
+                enabledSetting == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER -> true
+                else -> false
+            }
+    }
+}
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt
index d802b04..58d0f8d 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppInfo.kt
@@ -29,8 +29,10 @@
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import com.android.settingslib.spa.framework.compose.rememberDrawablePainter
+import com.android.settingslib.spa.framework.theme.SettingsDimension
 import com.android.settingslib.spa.widget.ui.SettingsBody
 import com.android.settingslib.spa.widget.ui.SettingsTitle
 import com.android.settingslib.spaprivileged.model.app.PackageManagers
@@ -45,7 +47,7 @@
         horizontalAlignment = Alignment.CenterHorizontally) {
         val packageInfo = remember { PackageManagers.getPackageInfoAsUser(packageName, userId) }
         Box(modifier = Modifier.padding(8.dp)) {
-            AppIcon(app = packageInfo.applicationInfo, size = 48)
+            AppIcon(app = packageInfo.applicationInfo, size = SettingsDimension.appIconInfoSize)
         }
         AppLabel(packageInfo.applicationInfo)
         Spacer(modifier = Modifier.height(4.dp))
@@ -54,12 +56,12 @@
 }
 
 @Composable
-fun AppIcon(app: ApplicationInfo, size: Int) {
+fun AppIcon(app: ApplicationInfo, size: Dp) {
     val appRepository = rememberAppRepository()
     Image(
         painter = rememberDrawablePainter(appRepository.produceIcon(app).value),
         contentDescription = null,
-        modifier = Modifier.size(size.dp)
+        modifier = Modifier.size(size)
     )
 }
 
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
new file mode 100644
index 0000000..c60976d
--- /dev/null
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppList.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spaprivileged.template.app
+
+import android.content.pm.UserInfo
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.android.settingslib.spa.framework.compose.LogCompositions
+import com.android.settingslib.spa.framework.compose.toState
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.widget.ui.PlaceholderTitle
+import com.android.settingslib.spaprivileged.R
+import com.android.settingslib.spaprivileged.model.app.AppListData
+import com.android.settingslib.spaprivileged.model.app.AppListModel
+import com.android.settingslib.spaprivileged.model.app.AppListViewModel
+import com.android.settingslib.spaprivileged.model.app.AppRecord
+import kotlinx.coroutines.Dispatchers
+
+private const val TAG = "AppList"
+
+@Composable
+fun <T : AppRecord> AppList(
+    userInfo: UserInfo,
+    listModel: AppListModel<T>,
+    showSystem: State<Boolean>,
+    option: State<Int>,
+    searchQuery: State<String>,
+    appItem: @Composable (itemState: AppListItemModel<T>) -> Unit,
+) {
+    LogCompositions(TAG, userInfo.id.toString())
+    val appListData = loadAppEntries(userInfo, listModel, showSystem, option, searchQuery)
+    AppListWidget(appListData, listModel, appItem)
+}
+
+@Composable
+private fun <T : AppRecord> AppListWidget(
+    appListData: State<AppListData<T>?>,
+    listModel: AppListModel<T>,
+    appItem: @Composable (itemState: AppListItemModel<T>) -> Unit,
+) {
+    appListData.value?.let { (list, option) ->
+        if (list.isEmpty()) {
+            PlaceholderTitle(stringResource(R.string.no_applications))
+            return
+        }
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            state = rememberLazyListState(),
+            contentPadding = PaddingValues(bottom = SettingsDimension.itemPaddingVertical),
+        ) {
+            items(count = list.size, key = { option to list[it].record.app.packageName }) {
+                val appEntry = list[it]
+                val summary = getSummary(listModel, option, appEntry.record)
+                val itemModel = remember(appEntry) {
+                    AppListItemModel(appEntry.record, appEntry.label, summary)
+                }
+                appItem(itemModel)
+            }
+        }
+    }
+}
+
+@Composable
+private fun <T : AppRecord> loadAppEntries(
+    userInfo: UserInfo,
+    listModel: AppListModel<T>,
+    showSystem: State<Boolean>,
+    option: State<Int>,
+    searchQuery: State<String>,
+): State<AppListData<T>?> {
+    val viewModel: AppListViewModel<T> = viewModel(key = userInfo.id.toString())
+    viewModel.userInfo.setIfAbsent(userInfo)
+    viewModel.listModel.setIfAbsent(listModel)
+    viewModel.showSystem.Sync(showSystem)
+    viewModel.option.Sync(option)
+    viewModel.searchQuery.Sync(searchQuery)
+
+    return viewModel.appListDataFlow.collectAsState(null, Dispatchers.Default)
+}
+
+@Composable
+private fun <T : AppRecord> getSummary(
+    listModel: AppListModel<T>,
+    option: Int,
+    record: T,
+): State<String> = remember(option) { listModel.getSummary(option, record) }
+    ?.collectAsState(stringResource(R.string.summary_placeholder), Dispatchers.Default)
+    ?: "".toState()
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppListItem.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppListItem.kt
new file mode 100644
index 0000000..ac3f8ff
--- /dev/null
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppListItem.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2022 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.settingslib.spaprivileged.template.app
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.android.settingslib.spa.framework.compose.toState
+import com.android.settingslib.spa.framework.theme.SettingsDimension
+import com.android.settingslib.spa.framework.theme.SettingsTheme
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+import com.android.settingslib.spaprivileged.model.app.AppRecord
+
+class AppListItemModel<T : AppRecord>(
+    val record: T,
+    val label: String,
+    val summary: State<String>,
+)
+
+@Composable
+fun <T : AppRecord> AppListItem(
+    itemModel: AppListItemModel<T>,
+    onClick: () -> Unit,
+) {
+    Preference(remember {
+        object : PreferenceModel {
+            override val title = itemModel.label
+            override val summary = itemModel.summary
+            override val icon = @Composable {
+                AppIcon(app = itemModel.record.app, size = SettingsDimension.appIconItemSize)
+            }
+            override val onClick = onClick
+        }
+    })
+}
+
+@Preview
+@Composable
+private fun AppListItemPreview() {
+    SettingsTheme {
+        val record = object : AppRecord {
+            override val app = LocalContext.current.applicationInfo
+        }
+        val itemModel = AppListItemModel<AppRecord>(record, "Chrome", "Allowed".toState())
+        AppListItem(itemModel) {}
+    }
+}