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
This commit is contained in:
Chaohui Wang
2022-11-09 11:08:26 +08:00
parent 2c0f41c8b3
commit 7c3abc7a6b
9 changed files with 263 additions and 72 deletions

View File

@@ -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")
}
}
}

View File

@@ -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)
}

View File

@@ -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<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> =

View File

@@ -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<T : AppRecord>(
@@ -43,9 +48,15 @@ internal data class AppListData<T : AppRecord>(
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 @@ internal class AppListViewModel<T : AppRecord>(
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 @@ internal class AppListViewModel<T : AppRecord>(
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 ->

View File

@@ -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<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?>
@@ -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<Drawable?>(initialValue = null, app) {

View File

@@ -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 <T : AppRecord> 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)
}

View File

@@ -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",

View File

@@ -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<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
}
}

View File

@@ -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
}
}