Refresh the AppList for changes

The AppList will reload the apps when every on start, it will also
register a broadcast receiver for package changes.

Bug: 235727273
Test: Manually with Settings
Test: Unit test
Change-Id: Iddc39f73db1cb6cc6a2d5e1f6dc459f46f7cdbb8
diff --git a/packages/SettingsLib/Spa/testutils/src/SpaTest.kt b/packages/SettingsLib/Spa/testutils/src/SpaTest.kt
new file mode 100644
index 0000000..a397bb4
--- /dev/null
+++ b/packages/SettingsLib/Spa/testutils/src/SpaTest.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.spa.testutils
+
+import java.util.concurrent.TimeoutException
+
+/**
+ * Blocks until the given condition is satisfied.
+ */
+fun waitUntil(timeoutMillis: Long = 1000, condition: () -> Boolean) {
+    val startTime = System.currentTimeMillis()
+    while (!condition()) {
+        // Let Android run measure, draw and in general any other async operations.
+        Thread.sleep(10)
+        if (System.currentTimeMillis() - startTime > timeoutMillis) {
+            throw TimeoutException("Condition still not satisfied after $timeoutMillis ms")
+        }
+    }
+}
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/compose/DisposableBroadcastReceiverAsUser.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/compose/DisposableBroadcastReceiverAsUser.kt
index a7de4ce..b2ea4a0 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/compose/DisposableBroadcastReceiverAsUser.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/compose/DisposableBroadcastReceiverAsUser.kt
@@ -23,7 +23,6 @@
 import android.os.UserHandle
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalLifecycleOwner
 import androidx.lifecycle.Lifecycle
@@ -34,24 +33,25 @@
  */
 @Composable
 fun DisposableBroadcastReceiverAsUser(
-    userId: Int,
     intentFilter: IntentFilter,
+    userHandle: UserHandle,
+    onStart: () -> Unit = {},
     onReceive: (Intent) -> Unit,
 ) {
-    val broadcastReceiver = remember {
-        object : BroadcastReceiver() {
+    val context = LocalContext.current
+    val lifecycleOwner = LocalLifecycleOwner.current
+    DisposableEffect(lifecycleOwner) {
+        val broadcastReceiver = object : BroadcastReceiver() {
             override fun onReceive(context: Context, intent: Intent) {
                 onReceive(intent)
             }
         }
-    }
-    val context = LocalContext.current
-    val lifecycleOwner = LocalLifecycleOwner.current
-    DisposableEffect(lifecycleOwner) {
         val observer = LifecycleEventObserver { _, event ->
             if (event == Lifecycle.Event.ON_START) {
                 context.registerReceiverAsUser(
-                    broadcastReceiver, UserHandle.of(userId), intentFilter, null, null)
+                    broadcastReceiver, userHandle, intentFilter, null, null
+                )
+                onStart()
             } else if (event == Lifecycle.Event.ON_STOP) {
                 context.unregisterReceiver(broadcastReceiver)
             }
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListRepository.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListRepository.kt
index ee89003..487dbcb 100644
--- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListRepository.kt
+++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppListRepository.kt
@@ -21,13 +21,10 @@
 import android.content.pm.ApplicationInfo
 import android.content.pm.PackageManager
 import android.content.pm.ResolveInfo
-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
 
 /**
  * The config used to load the App List.
@@ -40,36 +37,42 @@
 /**
  * The repository to load the App List data.
  */
-internal class AppListRepository(context: Context) {
+internal interface AppListRepository {
+    /** Loads the list of [ApplicationInfo]. */
+    suspend fun loadApps(config: AppListConfig): List<ApplicationInfo>
+
+    /** Gets the flow of predicate that could used to filter system app. */
+    fun showSystemPredicate(
+        userIdFlow: Flow<Int>,
+        showSystemFlow: Flow<Boolean>,
+    ): Flow<(app: ApplicationInfo) -> Boolean>
+}
+
+
+internal class AppListRepositoryImpl(context: Context) : AppListRepository {
     private val packageManager = context.packageManager
 
-    fun loadApps(configFlow: Flow<AppListConfig>): Flow<List<ApplicationInfo>> = configFlow
-        .map { loadApps(it) }
-        .flowOn(Dispatchers.Default)
+    override suspend fun loadApps(config: AppListConfig): List<ApplicationInfo> = coroutineScope {
+        val hiddenSystemModulesDeferred = async {
+            packageManager.getInstalledModules(0)
+                .filter { it.isHidden }
+                .map { it.packageName }
+                .toSet()
+        }
+        val flags = PackageManager.ApplicationInfoFlags.of(
+            (PackageManager.MATCH_DISABLED_COMPONENTS or
+                PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS).toLong()
+        )
+        val installedApplicationsAsUser =
+            packageManager.getInstalledApplicationsAsUser(flags, config.userId)
 
-    private suspend fun loadApps(config: AppListConfig): List<ApplicationInfo> {
-        return coroutineScope {
-            val hiddenSystemModulesDeferred = async {
-                packageManager.getInstalledModules(0)
-                    .filter { it.isHidden }
-                    .map { it.packageName }
-                    .toSet()
-            }
-            val flags = PackageManager.ApplicationInfoFlags.of(
-                (PackageManager.MATCH_DISABLED_COMPONENTS or
-                    PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS).toLong()
-            )
-            val installedApplicationsAsUser =
-                packageManager.getInstalledApplicationsAsUser(flags, config.userId)
-
-            val hiddenSystemModules = hiddenSystemModulesDeferred.await()
-            installedApplicationsAsUser.filter { app ->
-                app.isInAppList(config.showInstantApps, hiddenSystemModules)
-            }
+        val hiddenSystemModules = hiddenSystemModulesDeferred.await()
+        installedApplicationsAsUser.filter { app ->
+            app.isInAppList(config.showInstantApps, hiddenSystemModules)
         }
     }
 
-    fun showSystemPredicate(
+    override fun showSystemPredicate(
         userIdFlow: Flow<Int>,
         showSystemFlow: Flow<Boolean>,
     ): Flow<(app: ApplicationInfo) -> Boolean> =
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
index 1e487da..650b278 100644
--- 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
@@ -17,6 +17,7 @@
 package com.android.settingslib.spaprivileged.model.app
 
 import android.app.Application
+import android.content.Context
 import android.content.pm.ApplicationInfo
 import android.icu.text.Collator
 import androidx.lifecycle.AndroidViewModel
@@ -27,12 +28,16 @@
 import java.util.concurrent.ConcurrentHashMap
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.plus
 
 internal data class AppListData<T : AppRecord>(
@@ -43,9 +48,15 @@
         AppListData(appEntries.filter(predicate), option)
 }
 
-@OptIn(ExperimentalCoroutinesApi::class)
 internal class AppListViewModel<T : AppRecord>(
     application: Application,
+) : AppListViewModelImpl<T>(application)
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal open class AppListViewModelImpl<T : AppRecord>(
+    application: Application,
+    appListRepositoryFactory: (Context) -> AppListRepository = ::AppListRepositoryImpl,
+    appRepositoryFactory: (Context) -> AppRepository = ::AppRepositoryImpl,
 ) : AndroidViewModel(application) {
     val appListConfig = StateFlowBridge<AppListConfig>()
     val listModel = StateFlowBridge<AppListModel<T>>()
@@ -53,16 +64,18 @@
     val option = StateFlowBridge<Int>()
     val searchQuery = StateFlowBridge<String>()
 
-    private val appListRepository = AppListRepository(application)
-    private val appRepository = AppRepositoryImpl(application)
+    private val appListRepository = appListRepositoryFactory(application)
+    private val appRepository = appRepositoryFactory(application)
     private val collator = Collator.getInstance().freeze()
     private val labelMap = ConcurrentHashMap<String, String>()
-    private val scope = viewModelScope + Dispatchers.Default
+    private val scope = viewModelScope + Dispatchers.IO
 
     private val userIdFlow = appListConfig.flow.map { it.userId }
 
+    private val appsStateFlow = MutableStateFlow<List<ApplicationInfo>?>(null)
+
     private val recordListFlow = listModel.flow
-        .flatMapLatest { it.transform(userIdFlow, appListRepository.loadApps(appListConfig.flow)) }
+        .flatMapLatest { it.transform(userIdFlow, appsStateFlow.filterNotNull()) }
         .shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1)
 
     private val systemFilteredFlow =
@@ -83,6 +96,12 @@
         scheduleOnFirstLoaded()
     }
 
+    fun reloadApps() {
+        viewModelScope.launch {
+            appsStateFlow.value = appListRepository.loadApps(appListConfig.flow.first())
+        }
+    }
+
     private fun filterAndSort(option: Int) = listModel.flow.flatMapLatest { listModel ->
         listModel.filter(userIdFlow, option, systemFilteredFlow)
             .asyncMapItem { record ->
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 34f12af..90710db 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
@@ -22,8 +22,10 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.produceState
+import androidx.compose.ui.res.stringResource
 import com.android.settingslib.Utils
 import com.android.settingslib.spa.framework.compose.rememberContext
+import com.android.settingslib.spaprivileged.R
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 
@@ -34,7 +36,12 @@
     fun loadLabel(app: ApplicationInfo): String
 
     @Composable
-    fun produceLabel(app: ApplicationInfo): State<String>
+    fun produceLabel(app: ApplicationInfo) =
+        produceState(initialValue = stringResource(R.string.summary_placeholder), app) {
+            withContext(Dispatchers.IO) {
+                value = loadLabel(app)
+            }
+        }
 
     @Composable
     fun produceIcon(app: ApplicationInfo): State<Drawable?>
@@ -46,13 +53,6 @@
     override fun loadLabel(app: ApplicationInfo): String = app.loadLabel(packageManager).toString()
 
     @Composable
-    override fun produceLabel(app: ApplicationInfo) = produceState(initialValue = "", app) {
-        withContext(Dispatchers.Default) {
-            value = app.loadLabel(packageManager).toString()
-        }
-    }
-
-    @Composable
     override fun produceIcon(app: ApplicationInfo) =
         produceState<Drawable?>(initialValue = null, app) {
             withContext(Dispatchers.Default) {
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 9d6b311..7f5fe9f 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
@@ -16,6 +16,9 @@
 
 package com.android.settingslib.spaprivileged.template.app
 
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.UserHandle
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.lazy.LazyColumn
@@ -33,6 +36,7 @@
 import com.android.settingslib.spa.framework.compose.toState
 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.AppListConfig
 import com.android.settingslib.spaprivileged.model.app.AppListData
 import com.android.settingslib.spaprivileged.model.app.AppListModel
@@ -120,5 +124,15 @@
     viewModel.option.Sync(state.option)
     viewModel.searchQuery.Sync(state.searchQuery)
 
-    return viewModel.appListDataFlow.collectAsState(null, Dispatchers.Default)
+    DisposableBroadcastReceiverAsUser(
+        intentFilter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
+            addAction(Intent.ACTION_PACKAGE_REMOVED)
+            addAction(Intent.ACTION_PACKAGE_CHANGED)
+            addDataScheme("package")
+        },
+        userHandle = UserHandle.of(config.userId),
+        onStart = { viewModel.reloadApps() },
+    ) { viewModel.reloadApps() }
+
+    return viewModel.appListDataFlow.collectAsState(null, Dispatchers.IO)
 }
diff --git a/packages/SettingsLib/SpaPrivileged/tests/Android.bp b/packages/SettingsLib/SpaPrivileged/tests/Android.bp
index 5afe21e..12955c8 100644
--- a/packages/SettingsLib/SpaPrivileged/tests/Android.bp
+++ b/packages/SettingsLib/SpaPrivileged/tests/Android.bp
@@ -31,6 +31,7 @@
     ],
 
     static_libs: [
+        "SpaLibTestUtils",
         "androidx.compose.ui_ui-test-junit4",
         "androidx.compose.ui_ui-test-manifest",
         "androidx.test.ext.junit",
diff --git a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppListRepositoryTest.kt b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppListRepositoryTest.kt
index 5d5a24e..bc6925b 100644
--- a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppListRepositoryTest.kt
+++ b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppListRepositoryTest.kt
@@ -24,9 +24,6 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.runTest
 import org.junit.Before
 import org.junit.Rule
@@ -36,11 +33,9 @@
 import org.mockito.Mockito.any
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.eq
-import org.mockito.Mockito.`when` as whenever
 import org.mockito.junit.MockitoJUnit
 import org.mockito.junit.MockitoRule
-
-private const val USER_ID = 0
+import org.mockito.Mockito.`when` as whenever
 
 @OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
@@ -80,36 +75,28 @@
             packageManager.queryIntentActivitiesAsUser(any(), any<ResolveInfoFlags>(), eq(USER_ID))
         ).thenReturn(emptyList())
 
-        repository = AppListRepository(context)
+        repository = AppListRepositoryImpl(context)
     }
 
     @Test
     fun notShowInstantApps() = runTest {
         val appListConfig = AppListConfig(userId = USER_ID, showInstantApps = false)
 
-        val appListFlow = repository.loadApps(flowOf(appListConfig))
+        val appListFlow = repository.loadApps(appListConfig)
 
-        launch {
-            val flowValues = mutableListOf<List<ApplicationInfo>>()
-            appListFlow.toList(flowValues)
-            assertThat(flowValues).hasSize(1)
-
-            assertThat(flowValues[0]).containsExactly(normalApp)
-        }
+        assertThat(appListFlow).containsExactly(normalApp)
     }
 
     @Test
     fun showInstantApps() = runTest {
         val appListConfig = AppListConfig(userId = USER_ID, showInstantApps = true)
 
-        val appListFlow = repository.loadApps(flowOf(appListConfig))
+        val appListFlow = repository.loadApps(appListConfig)
 
-        launch {
-            val flowValues = mutableListOf<List<ApplicationInfo>>()
-            appListFlow.toList(flowValues)
-            assertThat(flowValues).hasSize(1)
+        assertThat(appListFlow).containsExactly(normalApp, instantApp)
+    }
 
-            assertThat(flowValues[0]).containsExactly(normalApp, instantApp)
-        }
+    private companion object {
+        const val USER_ID = 0
     }
 }
diff --git a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppListViewModelTest.kt b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppListViewModelTest.kt
new file mode 100644
index 0000000..b570815
--- /dev/null
+++ b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppListViewModelTest.kt
@@ -0,0 +1,134 @@
+/*
+ * 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 androidx.compose.runtime.Composable
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.settingslib.spa.framework.compose.stateOf
+import com.android.settingslib.spa.framework.util.asyncMapItem
+import com.android.settingslib.spa.testutils.waitUntil
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class AppListViewModelTest {
+    @JvmField
+    @Rule
+    val mockito: MockitoRule = MockitoJUnit.rule()
+
+    @Mock
+    private lateinit var application: Application
+
+    private val listModel = TestAppListModel()
+
+    private fun createViewModel(): AppListViewModelImpl<TestAppRecord> {
+        val viewModel = AppListViewModelImpl<TestAppRecord>(
+            application = application,
+            appListRepositoryFactory = { FakeAppListRepository },
+            appRepositoryFactory = { FakeAppRepository },
+        )
+        viewModel.appListConfig.setIfAbsent(CONFIG)
+        viewModel.listModel.setIfAbsent(listModel)
+        viewModel.showSystem.setIfAbsent(false)
+        viewModel.option.setIfAbsent(0)
+        viewModel.searchQuery.setIfAbsent("")
+        viewModel.reloadApps()
+        return viewModel
+    }
+
+    @Test
+    fun appListDataFlow() = runTest {
+        val viewModel = createViewModel()
+
+        val (appEntries, option) = viewModel.appListDataFlow.first()
+
+        assertThat(appEntries).hasSize(1)
+        assertThat(appEntries[0].record.app).isSameInstanceAs(APP)
+        assertThat(appEntries[0].label).isEqualTo(LABEL)
+        assertThat(option).isEqualTo(0)
+    }
+
+    @Test
+    fun onFirstLoaded_calledWhenLoaded() = runTest {
+        val viewModel = createViewModel()
+
+        viewModel.appListDataFlow.first()
+
+        waitUntil { listModel.onFirstLoadedCalled }
+    }
+
+    private object FakeAppListRepository : AppListRepository {
+        override suspend fun loadApps(config: AppListConfig) = listOf(APP)
+
+        override fun showSystemPredicate(
+            userIdFlow: Flow<Int>,
+            showSystemFlow: Flow<Boolean>,
+        ): Flow<(app: ApplicationInfo) -> Boolean> = flowOf { true }
+    }
+
+    private object FakeAppRepository : AppRepository {
+        override fun loadLabel(app: ApplicationInfo) = LABEL
+
+        @Composable
+        override fun produceIcon(app: ApplicationInfo) = stateOf(null)
+    }
+
+    private companion object {
+        const val USER_ID = 0
+        const val PACKAGE_NAME = "package.name"
+        const val LABEL = "Label"
+        val CONFIG = AppListConfig(userId = USER_ID, showInstantApps = false)
+        val APP = ApplicationInfo().apply {
+            packageName = PACKAGE_NAME
+        }
+    }
+}
+
+private data class TestAppRecord(override val app: ApplicationInfo) : AppRecord
+
+private class TestAppListModel : AppListModel<TestAppRecord> {
+    var onFirstLoadedCalled = false
+
+    override fun transform(userIdFlow: Flow<Int>, appListFlow: Flow<List<ApplicationInfo>>) =
+        appListFlow.asyncMapItem { TestAppRecord(it) }
+
+    @Composable
+    override fun getSummary(option: Int, record: TestAppRecord) = null
+
+    override fun filter(
+        userIdFlow: Flow<Int>,
+        option: Int,
+        recordListFlow: Flow<List<TestAppRecord>>,
+    ) = recordListFlow
+
+    override suspend fun onFirstLoaded(recordList: List<TestAppRecord>) {
+        onFirstLoadedCalled = true
+    }
+}