diff --git a/packages/SettingsLib/Spa/testutils/src/SpaTest.kt b/packages/SettingsLib/Spa/testutils/src/SpaTest.kt new file mode 100644 index 0000000000000..a397bb48103c1 --- /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 a7de4ce18b323..b2ea4a084e480 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.content.IntentFilter 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 @@ import androidx.lifecycle.LifecycleEventObserver */ @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 ee8900352cf20..487dbcb299279 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.Intent 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 @@ internal data class AppListConfig( /** * 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 + + /** Gets the flow of predicate that could used to filter system app. */ + fun showSystemPredicate( + userIdFlow: Flow, + showSystemFlow: Flow, + ): Flow<(app: ApplicationInfo) -> Boolean> +} + + +internal class AppListRepositoryImpl(context: Context) : AppListRepository { private val packageManager = context.packageManager - fun loadApps(configFlow: Flow): Flow> = configFlow - .map { loadApps(it) } - .flowOn(Dispatchers.Default) + override suspend fun loadApps(config: AppListConfig): List = 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 { - 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, showSystemFlow: Flow, ): 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 1e487daa36fb7..650b27845bd21 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 com.android.settingslib.spa.framework.util.waitFirst 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( @@ -43,9 +48,15 @@ internal data class AppListData( AppListData(appEntries.filter(predicate), option) } -@OptIn(ExperimentalCoroutinesApi::class) internal class AppListViewModel( application: Application, +) : AppListViewModelImpl(application) + +@OptIn(ExperimentalCoroutinesApi::class) +internal open class AppListViewModelImpl( + application: Application, + appListRepositoryFactory: (Context) -> AppListRepository = ::AppListRepositoryImpl, + appRepositoryFactory: (Context) -> AppRepository = ::AppRepositoryImpl, ) : AndroidViewModel(application) { val appListConfig = StateFlowBridge() val listModel = StateFlowBridge>() @@ -53,16 +64,18 @@ internal class AppListViewModel( val option = StateFlowBridge() val searchQuery = StateFlowBridge() - 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() - private val scope = viewModelScope + Dispatchers.Default + private val scope = viewModelScope + Dispatchers.IO private val userIdFlow = appListConfig.flow.map { it.userId } + private val appsStateFlow = MutableStateFlow?>(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 @@ internal class AppListViewModel( 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 34f12af28dce5..90710db6388bd 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 android.graphics.drawable.Drawable 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 @@ interface AppRepository { fun loadLabel(app: ApplicationInfo): String @Composable - fun produceLabel(app: ApplicationInfo): State + fun produceLabel(app: ApplicationInfo) = + produceState(initialValue = stringResource(R.string.summary_placeholder), app) { + withContext(Dispatchers.IO) { + value = loadLabel(app) + } + } @Composable fun produceIcon(app: ApplicationInfo): State @@ -45,13 +52,6 @@ internal class AppRepositoryImpl(private val context: Context) : AppRepository { 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(initialValue = null, app) { 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 9d6b311a33cb7..7f5fe9f75d51f 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.rememberLazyListStateAndHid 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 @@ private fun loadAppListData( 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 5afe21e9a6911..12955c8874809 100644 --- a/packages/SettingsLib/SpaPrivileged/tests/Android.bp +++ b/packages/SettingsLib/SpaPrivileged/tests/Android.bp @@ -31,6 +31,7 @@ android_test { ], 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 5d5a24eed0c37..bc6925baacc2a 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 android.content.pm.PackageManager.ResolveInfoFlags 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.Mock 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 @@ class AppListRepositoryTest { packageManager.queryIntentActivitiesAsUser(any(), any(), 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>() - 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>() - 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 0000000000000..b570815b41806 --- /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 { + val viewModel = AppListViewModelImpl( + 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, + showSystemFlow: Flow, + ): 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 { + var onFirstLoadedCalled = false + + override fun transform(userIdFlow: Flow, appListFlow: Flow>) = + appListFlow.asyncMapItem { TestAppRecord(it) } + + @Composable + override fun getSummary(option: Int, record: TestAppRecord) = null + + override fun filter( + userIdFlow: Flow, + option: Int, + recordListFlow: Flow>, + ) = recordListFlow + + override suspend fun onFirstLoaded(recordList: List) { + onFirstLoadedCalled = true + } +}