From 378447f6948edf1b1f66b0711f9bdb76c2d3721a Mon Sep 17 00:00:00 2001 From: Fabian Kozynski Date: Thu, 11 May 2023 19:54:54 +0000 Subject: [PATCH] Support restoring TileService before app install Before, CustomTile were destroyed if the app was not installed (before that, they were kept as UNAVAILABLE, but that caused other issues). This was an issue when restoring from a backup, as the setting would include custom tiles that were restored before the app was installed and therefore immediately removed (and never brought back). With this change: * Do not create CustomTile that don't have a corresponding valid component. Instead, keep them in the setting and mark them as NotInstalled internally. * If the component later appears, actually create the tile, and send `onTileAdded`. Note that if a component for a TileService disappears (it's uninstalled), the tile will be automatically removed and would need to be manually added. This is existing behavior. Finally, in order to prevent race conditions when a package with multiple tiles is uninstalled, use a mutex whenever making changes to the setting. Fixes: 282003318 Test: atest com.android.systemui.qs Test: atest CtsTileServiceTestCases Test: manual, add custom tiles through adb to settings and later install app Test: atest android.host.systemui Flag: QS_PIPELINE_NEW_HOST Change-Id: I0a774969ad20611103b09ce4ace6175ad53350ce Change-Id: I8b48f45124928702fcdcf88484ccf3b501ea86ed --- .../systemui/qs/external/CustomTile.java | 4 +- .../qs/pipeline/dagger/QSPipelineModule.kt | 7 + .../InstalledTilesComponentRepository.kt | 109 +++++++ .../data/repository/TileSpecRepository.kt | 57 ++-- .../interactor/CurrentTilesInteractor.kt | 239 +++++++++------ .../shared/logging/QSPipelineLogger.kt | 12 + .../systemui/util/kotlin/PackageManagerExt.kt | 33 +++ ...stalledTilesComponentRepositoryImplTest.kt | 278 ++++++++++++++++++ .../CurrentTilesInteractorImplTest.kt | 39 +++ .../PackageManagerExtComponentEnabledTest.kt | 153 ++++++++++ .../FakeInstalledTilesComponentRepository.kt | 39 +++ 11 files changed, 859 insertions(+), 111 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepository.kt create mode 100644 packages/SystemUI/src/com/android/systemui/util/kotlin/PackageManagerExt.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepositoryImplTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/util/kotlin/PackageManagerExtComponentEnabledTest.kt create mode 100644 packages/SystemUI/tests/utils/src/com/android/systemui/qs/pipeline/data/repository/FakeInstalledTilesComponentRepository.kt diff --git a/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java b/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java index 897b0e73dca07..5d028307a62d4 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java +++ b/packages/SystemUI/src/com/android/systemui/qs/external/CustomTile.java @@ -65,14 +65,12 @@ import com.android.systemui.qs.logging.QSLogger; import com.android.systemui.qs.tileimpl.QSTileImpl; import com.android.systemui.settings.DisplayTracker; -import dagger.Lazy; - import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import javax.inject.Inject; - +import dagger.Lazy; public class CustomTile extends QSTileImpl implements TileChangeListener { public static final String PREFIX = "custom("; diff --git a/packages/SystemUI/src/com/android/systemui/qs/pipeline/dagger/QSPipelineModule.kt b/packages/SystemUI/src/com/android/systemui/qs/pipeline/dagger/QSPipelineModule.kt index a066242fd96bf..d7ae575724dd4 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/pipeline/dagger/QSPipelineModule.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/pipeline/dagger/QSPipelineModule.kt @@ -20,6 +20,8 @@ import com.android.systemui.CoreStartable import com.android.systemui.dagger.SysUISingleton import com.android.systemui.log.LogBuffer import com.android.systemui.log.LogBufferFactory +import com.android.systemui.qs.pipeline.data.repository.InstalledTilesComponentRepository +import com.android.systemui.qs.pipeline.data.repository.InstalledTilesComponentRepositoryImpl import com.android.systemui.qs.pipeline.data.repository.TileSpecRepository import com.android.systemui.qs.pipeline.data.repository.TileSpecSettingsRepository import com.android.systemui.qs.pipeline.domain.interactor.CurrentTilesInteractor @@ -44,6 +46,11 @@ abstract class QSPipelineModule { impl: CurrentTilesInteractorImpl ): CurrentTilesInteractor + @Binds + abstract fun provideInstalledTilesPackageRepository( + impl: InstalledTilesComponentRepositoryImpl + ): InstalledTilesComponentRepository + @Binds @IntoMap @ClassKey(PrototypeCoreStartable::class) diff --git a/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepository.kt new file mode 100644 index 0000000000000..498f403e8c7ae --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepository.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2023 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.systemui.qs.pipeline.data.repository + +import android.Manifest.permission.BIND_QUICK_SETTINGS_TILE +import android.annotation.WorkerThread +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ResolveInfoFlags +import android.os.UserHandle +import android.service.quicksettings.TileService +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.util.kotlin.isComponentActuallyEnabled +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +interface InstalledTilesComponentRepository { + + fun getInstalledTilesComponents(userId: Int): Flow> +} + +@SysUISingleton +class InstalledTilesComponentRepositoryImpl +@Inject +constructor( + @Application private val applicationContext: Context, + private val packageManager: PackageManager, + @Background private val backgroundDispatcher: CoroutineDispatcher, +) : InstalledTilesComponentRepository { + + override fun getInstalledTilesComponents(userId: Int): Flow> = + conflatedCallbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + trySend(Unit) + } + } + applicationContext.registerReceiverAsUser( + receiver, + UserHandle.of(userId), + INTENT_FILTER, + /* broadcastPermission = */ null, + /* scheduler = */ null + ) + + awaitClose { applicationContext.unregisterReceiver(receiver) } + } + .onStart { emit(Unit) } + .map { reloadComponents(userId) } + .distinctUntilChanged() + .flowOn(backgroundDispatcher) + + @WorkerThread + private fun reloadComponents(userId: Int): Set { + return packageManager + .queryIntentServicesAsUser(INTENT, FLAGS, userId) + .mapNotNull { it.serviceInfo } + .filter { it.permission == BIND_QUICK_SETTINGS_TILE } + .filter { packageManager.isComponentActuallyEnabled(it) } + .mapTo(mutableSetOf()) { it.componentName } + } + + companion object { + private val INTENT_FILTER = + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_CHANGED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addDataScheme("package") + } + private val INTENT = Intent(TileService.ACTION_QS_TILE) + private val FLAGS = + ResolveInfoFlags.of( + (PackageManager.GET_SERVICES or + PackageManager.MATCH_DIRECT_BOOT_AWARE or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE) + .toLong() + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/TileSpecRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/TileSpecRepository.kt index 3b2362f2b3260..a162d113a3b2e 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/TileSpecRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/TileSpecRepository.kt @@ -42,6 +42,8 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** Repository that tracks the current tiles. */ @@ -104,6 +106,8 @@ constructor( @Background private val backgroundDispatcher: CoroutineDispatcher, ) : TileSpecRepository { + private val mutex = Mutex() + private val retailModeTiles by lazy { resources .getString(R.string.quick_settings_tiles_retail_mode) @@ -145,37 +149,40 @@ constructor( .flowOn(backgroundDispatcher) } - override suspend fun addTile(userId: Int, tile: TileSpec, position: Int) { - if (tile == TileSpec.Invalid) { - return - } - val tilesList = loadTiles(userId).toMutableList() - if (tile !in tilesList) { - if (position < 0 || position >= tilesList.size) { - tilesList.add(tile) - } else { - tilesList.add(position, tile) + override suspend fun addTile(userId: Int, tile: TileSpec, position: Int) = + mutex.withLock { + if (tile == TileSpec.Invalid) { + return + } + val tilesList = loadTiles(userId).toMutableList() + if (tile !in tilesList) { + if (position < 0 || position >= tilesList.size) { + tilesList.add(tile) + } else { + tilesList.add(position, tile) + } + storeTiles(userId, tilesList) } - storeTiles(userId, tilesList) } - } - override suspend fun removeTiles(userId: Int, tiles: Collection) { - if (tiles.all { it == TileSpec.Invalid }) { - return + override suspend fun removeTiles(userId: Int, tiles: Collection) = + mutex.withLock { + if (tiles.all { it == TileSpec.Invalid }) { + return + } + val tilesList = loadTiles(userId).toMutableList() + if (tilesList.removeAll(tiles)) { + storeTiles(userId, tilesList.toList()) + } } - val tilesList = loadTiles(userId).toMutableList() - if (tilesList.removeAll(tiles)) { - storeTiles(userId, tilesList.toList()) - } - } - override suspend fun setTiles(userId: Int, tiles: List) { - val filtered = tiles.filter { it != TileSpec.Invalid } - if (filtered.isNotEmpty()) { - storeTiles(userId, filtered) + override suspend fun setTiles(userId: Int, tiles: List) = + mutex.withLock { + val filtered = tiles.filter { it != TileSpec.Invalid } + if (filtered.isNotEmpty()) { + storeTiles(userId, filtered) + } } - } private suspend fun loadTiles(@UserIdInt forUser: Int): List { return withContext(backgroundDispatcher) { diff --git a/packages/SystemUI/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractor.kt index c579f5c3061cf..ff881f767b877 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractor.kt @@ -36,6 +36,7 @@ import com.android.systemui.qs.external.CustomTileStatePersister import com.android.systemui.qs.external.TileLifecycleManager import com.android.systemui.qs.external.TileServiceKey import com.android.systemui.qs.pipeline.data.repository.CustomTileAddedRepository +import com.android.systemui.qs.pipeline.data.repository.InstalledTilesComponentRepository import com.android.systemui.qs.pipeline.data.repository.TileSpecRepository import com.android.systemui.qs.pipeline.domain.model.TileModel import com.android.systemui.qs.pipeline.shared.TileSpec @@ -52,6 +53,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn @@ -117,11 +120,13 @@ interface CurrentTilesInteractor : ProtoDumpable { * * Platform tiles will be kept between users, with a call to [QSTile.userSwitch] * * [CustomTile]s will only be destroyed if the user changes. */ +@OptIn(ExperimentalCoroutinesApi::class) @SysUISingleton class CurrentTilesInteractorImpl @Inject constructor( private val tileSpecRepository: TileSpecRepository, + private val installedTilesComponentRepository: InstalledTilesComponentRepository, private val userRepository: UserRepository, private val customTileStatePersister: CustomTileStatePersister, private val tileFactory: QSFactory, @@ -141,7 +146,7 @@ constructor( override val currentTiles: StateFlow> = _currentSpecsAndTiles.asStateFlow() // This variable should only be accessed inside the collect of `startTileCollection`. - private val specsToTiles = mutableMapOf() + private val specsToTiles = mutableMapOf() private val currentUser = MutableStateFlow(userTracker.userId) override val userId = currentUser.asStateFlow() @@ -149,6 +154,20 @@ constructor( private val _userContext = MutableStateFlow(userTracker.userContext) override val userContext = _userContext.asStateFlow() + private val userAndTiles = + currentUser + .flatMapLatest { userId -> + tileSpecRepository.tilesSpecs(userId).map { UserAndTiles(userId, it) } + } + .distinctUntilChanged() + .pairwise(UserAndTiles(-1, emptyList())) + .flowOn(backgroundDispatcher) + + private val installedPackagesWithTiles = + currentUser.flatMapLatest { + installedTilesComponentRepository.getInstalledTilesComponents(it) + } + init { if (featureFlags.isEnabled(Flags.QS_PIPELINE_NEW_HOST)) { startTileCollection() @@ -158,68 +177,98 @@ constructor( @OptIn(ExperimentalCoroutinesApi::class) private fun startTileCollection() { scope.launch { - userRepository.selectedUserInfo - .flatMapLatest { user -> + launch { + userRepository.selectedUserInfo.collect { user -> currentUser.value = user.id _userContext.value = userTracker.userContext - tileSpecRepository.tilesSpecs(user.id).map { user.id to it } } - .distinctUntilChanged() - .pairwise(-1 to emptyList()) - .flowOn(backgroundDispatcher) - .collect { (old, new) -> - val newTileList = new.second - val userChanged = old.first != new.first - val newUser = new.first + } - // Destroy all tiles that are not in the new set - specsToTiles - .filter { it.key !in newTileList } - .forEach { entry -> - logger.logTileDestroyed( - entry.key, - if (userChanged) { - QSPipelineLogger.TileDestroyedReason - .TILE_NOT_PRESENT_IN_NEW_USER - } else { - QSPipelineLogger.TileDestroyedReason.TILE_REMOVED - } - ) - entry.value.destroy() - } - // MutableMap will keep the insertion order - val newTileMap = mutableMapOf() + launch(backgroundDispatcher) { + userAndTiles + .combine(installedPackagesWithTiles) { usersAndTiles, packages -> + Data( + usersAndTiles.previousValue, + usersAndTiles.newValue, + packages, + ) + } + .collectLatest { + val newTileList = it.newData.tiles + val userChanged = it.oldData.userId != it.newData.userId + val newUser = it.newData.userId + val components = it.installedComponents - newTileList.forEach { tileSpec -> - if (tileSpec !in newTileMap) { - val newTile = - if (tileSpec in specsToTiles) { - processExistingTile( - tileSpec, - specsToTiles.getValue(tileSpec), - userChanged, - newUser - ) - ?: createTile(tileSpec) + // Destroy all tiles that are not in the new set + specsToTiles + .filter { + it.key !in newTileList && it.value is TileOrNotInstalled.Tile + } + .forEach { entry -> + logger.logTileDestroyed( + entry.key, + if (userChanged) { + QSPipelineLogger.TileDestroyedReason + .TILE_NOT_PRESENT_IN_NEW_USER + } else { + QSPipelineLogger.TileDestroyedReason.TILE_REMOVED + } + ) + (entry.value as TileOrNotInstalled.Tile).tile.destroy() + } + // MutableMap will keep the insertion order + val newTileMap = mutableMapOf() + + newTileList.forEach { tileSpec -> + if (tileSpec !in newTileMap) { + if ( + tileSpec is TileSpec.CustomTileSpec && + tileSpec.componentName !in components + ) { + newTileMap[tileSpec] = TileOrNotInstalled.NotInstalled } else { - createTile(tileSpec) + // Create tile here will never try to create a CustomTile that + // is not installed + val newTile = + if (tileSpec in specsToTiles) { + processExistingTile( + tileSpec, + specsToTiles.getValue(tileSpec), + userChanged, + newUser + ) + ?: createTile(tileSpec) + } else { + createTile(tileSpec) + } + if (newTile != null) { + newTileMap[tileSpec] = TileOrNotInstalled.Tile(newTile) + } } - if (newTile != null) { - newTileMap[tileSpec] = newTile } } - } - val resolvedSpecs = newTileMap.keys.toList() - specsToTiles.clear() - specsToTiles.putAll(newTileMap) - _currentSpecsAndTiles.value = newTileMap.map { TileModel(it.key, it.value) } - if (resolvedSpecs != newTileList) { - // There were some tiles that couldn't be created. Change the value in the - // repository - launch { tileSpecRepository.setTiles(currentUser.value, resolvedSpecs) } + val resolvedSpecs = newTileMap.keys.toList() + specsToTiles.clear() + specsToTiles.putAll(newTileMap) + _currentSpecsAndTiles.value = + newTileMap + .filter { it.value is TileOrNotInstalled.Tile } + .map { + TileModel(it.key, (it.value as TileOrNotInstalled.Tile).tile) + } + logger.logTilesNotInstalled( + newTileMap.filter { it.value is TileOrNotInstalled.NotInstalled }.keys, + newUser + ) + if (resolvedSpecs != newTileList) { + // There were some tiles that couldn't be created. Change the value in + // the + // repository + launch { tileSpecRepository.setTiles(currentUser.value, resolvedSpecs) } + } } - } + } } } @@ -301,42 +350,66 @@ constructor( private fun processExistingTile( tileSpec: TileSpec, - qsTile: QSTile, + tileOrNotInstalled: TileOrNotInstalled, userChanged: Boolean, user: Int, ): QSTile? { - return when { - !qsTile.isAvailable -> { - logger.logTileDestroyed( - tileSpec, - QSPipelineLogger.TileDestroyedReason.EXISTING_TILE_NOT_AVAILABLE - ) - qsTile.destroy() - null - } - // Tile is in the current list of tiles and available. - // We have a handful of different cases - qsTile !is CustomTile -> { - // The tile is not a custom tile. Make sure they are reset to the correct user - if (userChanged) { - qsTile.userSwitch(user) - logger.logTileUserChanged(tileSpec, user) + return when (tileOrNotInstalled) { + is TileOrNotInstalled.NotInstalled -> null + is TileOrNotInstalled.Tile -> { + val qsTile = tileOrNotInstalled.tile + when { + !qsTile.isAvailable -> { + logger.logTileDestroyed( + tileSpec, + QSPipelineLogger.TileDestroyedReason.EXISTING_TILE_NOT_AVAILABLE + ) + qsTile.destroy() + null + } + // Tile is in the current list of tiles and available. + // We have a handful of different cases + qsTile !is CustomTile -> { + // The tile is not a custom tile. Make sure they are reset to the correct + // user + if (userChanged) { + qsTile.userSwitch(user) + logger.logTileUserChanged(tileSpec, user) + } + qsTile + } + qsTile.user == user -> { + // The tile is a custom tile for the same user, just return it + qsTile + } + else -> { + // The tile is a custom tile and the user has changed. Destroy it + qsTile.destroy() + logger.logTileDestroyed( + tileSpec, + QSPipelineLogger.TileDestroyedReason.CUSTOM_TILE_USER_CHANGED + ) + null + } } - qsTile - } - qsTile.user == user -> { - // The tile is a custom tile for the same user, just return it - qsTile - } - else -> { - // The tile is a custom tile and the user has changed. Destroy it - qsTile.destroy() - logger.logTileDestroyed( - tileSpec, - QSPipelineLogger.TileDestroyedReason.CUSTOM_TILE_USER_CHANGED - ) - null } } } + + private sealed interface TileOrNotInstalled { + object NotInstalled : TileOrNotInstalled + + @JvmInline value class Tile(val tile: QSTile) : TileOrNotInstalled + } + + private data class UserAndTiles( + val userId: Int, + val tiles: List, + ) + + private data class Data( + val oldData: UserAndTiles, + val newData: UserAndTiles, + val installedComponents: Set, + ) } diff --git a/packages/SystemUI/src/com/android/systemui/qs/pipeline/shared/logging/QSPipelineLogger.kt b/packages/SystemUI/src/com/android/systemui/qs/pipeline/shared/logging/QSPipelineLogger.kt index ff7d2068bc4e9..8318ec99e530d 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/pipeline/shared/logging/QSPipelineLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/pipeline/shared/logging/QSPipelineLogger.kt @@ -124,6 +124,18 @@ constructor( tileListLogBuffer.log(TILE_LIST_TAG, LogLevel.DEBUG, {}, { "Using retail tiles" }) } + fun logTilesNotInstalled(tiles: Collection, user: Int) { + tileListLogBuffer.log( + TILE_LIST_TAG, + LogLevel.DEBUG, + { + str1 = tiles.toString() + int1 = user + }, + { "Tiles kept for not installed packages for user $int1: $str1" } + ) + } + /** Reasons for destroying an existing tile. */ enum class TileDestroyedReason(val readable: String) { TILE_REMOVED("Tile removed from current set"), diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/PackageManagerExt.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/PackageManagerExt.kt new file mode 100644 index 0000000000000..891ee0cf66d79 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/PackageManagerExt.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2023 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.systemui.util.kotlin + +import android.annotation.WorkerThread +import android.content.pm.ComponentInfo +import android.content.pm.PackageManager +import com.android.systemui.util.Assert + +@WorkerThread +fun PackageManager.isComponentActuallyEnabled(componentInfo: ComponentInfo): Boolean { + Assert.isNotMainThread() + return when (getComponentEnabledSetting(componentInfo.componentName)) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> false + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> componentInfo.isEnabled + else -> false + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepositoryImplTest.kt new file mode 100644 index 0000000000000..18f3837a7d36e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/InstalledTilesComponentRepositoryImplTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2023 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.systemui.qs.pipeline.data.repository + +import android.Manifest.permission.BIND_QUICK_SETTINGS_TILE +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ResolveInfoFlags +import android.content.pm.ResolveInfo +import android.content.pm.ServiceInfo +import android.os.UserHandle +import android.service.quicksettings.TileService +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.argThat +import com.android.systemui.util.mockito.argumentCaptor +import com.android.systemui.util.mockito.capture +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.nullable +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class InstalledTilesComponentRepositoryImplTest : SysuiTestCase() { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Mock private lateinit var context: Context + @Mock private lateinit var packageManager: PackageManager + @Captor private lateinit var receiverCaptor: ArgumentCaptor + + private lateinit var underTest: InstalledTilesComponentRepositoryImpl + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + // Use the default value set in the ServiceInfo + whenever(packageManager.getComponentEnabledSetting(any())) + .thenReturn(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) + + // Return empty by default + whenever(packageManager.queryIntentServicesAsUser(any(), any(), anyInt())) + .thenReturn(emptyList()) + + underTest = + InstalledTilesComponentRepositoryImpl( + context, + packageManager, + testDispatcher, + ) + } + + @Test + fun registersAndUnregistersBroadcastReceiver() = + testScope.runTest { + val user = 10 + val job = launch { underTest.getInstalledTilesComponents(user).collect {} } + runCurrent() + + verify(context) + .registerReceiverAsUser( + capture(receiverCaptor), + eq(UserHandle.of(user)), + any(), + nullable(), + nullable(), + ) + + verify(context, never()).unregisterReceiver(receiverCaptor.value) + + job.cancel() + runCurrent() + verify(context).unregisterReceiver(receiverCaptor.value) + } + + @Test + fun intentFilterForCorrectActionsAndScheme() = + testScope.runTest { + val filterCaptor = argumentCaptor() + + backgroundScope.launch { underTest.getInstalledTilesComponents(0).collect {} } + runCurrent() + + verify(context) + .registerReceiverAsUser( + any(), + any(), + capture(filterCaptor), + nullable(), + nullable(), + ) + + with(filterCaptor.value) { + assertThat(matchAction(Intent.ACTION_PACKAGE_CHANGED)).isTrue() + assertThat(matchAction(Intent.ACTION_PACKAGE_ADDED)).isTrue() + assertThat(matchAction(Intent.ACTION_PACKAGE_REMOVED)).isTrue() + assertThat(matchAction(Intent.ACTION_PACKAGE_REPLACED)).isTrue() + assertThat(countActions()).isEqualTo(4) + + assertThat(hasDataScheme("package")).isTrue() + assertThat(countDataSchemes()).isEqualTo(1) + } + } + + @Test + fun componentsLoadedOnStart() = + testScope.runTest { + val userId = 0 + val resolveInfo = + ResolveInfo(TEST_COMPONENT, hasPermission = true, defaultEnabled = true) + whenever( + packageManager.queryIntentServicesAsUser( + matchIntent(), + matchFlags(), + eq(userId) + ) + ) + .thenReturn(listOf(resolveInfo)) + + val componentNames by collectLastValue(underTest.getInstalledTilesComponents(userId)) + + assertThat(componentNames).containsExactly(TEST_COMPONENT) + } + + @Test + fun componentAdded_foundAfterBroadcast() = + testScope.runTest { + val userId = 0 + val resolveInfo = + ResolveInfo(TEST_COMPONENT, hasPermission = true, defaultEnabled = true) + + val componentNames by collectLastValue(underTest.getInstalledTilesComponents(userId)) + assertThat(componentNames).isEmpty() + + whenever( + packageManager.queryIntentServicesAsUser( + matchIntent(), + matchFlags(), + eq(userId) + ) + ) + .thenReturn(listOf(resolveInfo)) + getRegisteredReceiver().onReceive(context, Intent(Intent.ACTION_PACKAGE_ADDED)) + + assertThat(componentNames).containsExactly(TEST_COMPONENT) + } + + @Test + fun componentWithoutPermission_notValid() = + testScope.runTest { + val userId = 0 + val resolveInfo = + ResolveInfo(TEST_COMPONENT, hasPermission = false, defaultEnabled = true) + whenever( + packageManager.queryIntentServicesAsUser( + matchIntent(), + matchFlags(), + eq(userId) + ) + ) + .thenReturn(listOf(resolveInfo)) + + val componentNames by collectLastValue(underTest.getInstalledTilesComponents(userId)) + assertThat(componentNames).isEmpty() + } + + @Test + fun componentNotEnabled_notValid() = + testScope.runTest { + val userId = 0 + val resolveInfo = + ResolveInfo(TEST_COMPONENT, hasPermission = true, defaultEnabled = false) + whenever( + packageManager.queryIntentServicesAsUser( + matchIntent(), + matchFlags(), + eq(userId) + ) + ) + .thenReturn(listOf(resolveInfo)) + + val componentNames by collectLastValue(underTest.getInstalledTilesComponents(userId)) + assertThat(componentNames).isEmpty() + } + + private fun getRegisteredReceiver(): BroadcastReceiver { + verify(context) + .registerReceiverAsUser( + capture(receiverCaptor), + any(), + any(), + nullable(), + nullable(), + ) + + return receiverCaptor.value + } + + companion object { + private val INTENT = Intent(TileService.ACTION_QS_TILE) + private val FLAGS = + ResolveInfoFlags.of( + (PackageManager.MATCH_DIRECT_BOOT_AWARE or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE or + PackageManager.GET_SERVICES) + .toLong() + ) + private val PERMISSION = BIND_QUICK_SETTINGS_TILE + + private val TEST_COMPONENT = ComponentName("pkg", "cls") + + private fun matchFlags() = + argThat { flags -> flags?.value == FLAGS.value } + private fun matchIntent() = argThat { intent -> intent.action == INTENT.action } + + private fun ResolveInfo( + componentName: ComponentName, + hasPermission: Boolean, + defaultEnabled: Boolean + ): ResolveInfo { + val applicationInfo = ApplicationInfo().apply { enabled = true } + val serviceInfo = + ServiceInfo().apply { + packageName = componentName.packageName + name = componentName.className + if (hasPermission) { + permission = PERMISSION + } + enabled = defaultEnabled + this.applicationInfo = applicationInfo + } + val resolveInfo = ResolveInfo() + resolveInfo.serviceInfo = serviceInfo + return resolveInfo + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractorImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractorImplTest.kt index 426ff670802fa..e7ad4896810bd 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractorImplTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/domain/interactor/CurrentTilesInteractorImplTest.kt @@ -38,6 +38,7 @@ import com.android.systemui.qs.external.TileLifecycleManager import com.android.systemui.qs.external.TileServiceKey import com.android.systemui.qs.pipeline.data.repository.CustomTileAddedRepository import com.android.systemui.qs.pipeline.data.repository.FakeCustomTileAddedRepository +import com.android.systemui.qs.pipeline.data.repository.FakeInstalledTilesComponentRepository import com.android.systemui.qs.pipeline.data.repository.FakeTileSpecRepository import com.android.systemui.qs.pipeline.data.repository.TileSpecRepository import com.android.systemui.qs.pipeline.domain.model.TileModel @@ -73,6 +74,7 @@ class CurrentTilesInteractorImplTest : SysuiTestCase() { private val tileSpecRepository: TileSpecRepository = FakeTileSpecRepository() private val userRepository = FakeUserRepository() + private val installedTilesPackageRepository = FakeInstalledTilesComponentRepository() private val tileFactory = FakeQSFactory(::tileCreator) private val customTileAddedRepository: CustomTileAddedRepository = FakeCustomTileAddedRepository() @@ -100,11 +102,13 @@ class CurrentTilesInteractorImplTest : SysuiTestCase() { featureFlags.set(Flags.QS_PIPELINE_NEW_HOST, true) userRepository.setUserInfos(listOf(USER_INFO_0, USER_INFO_1)) + setUserTracker(0) underTest = CurrentTilesInteractorImpl( tileSpecRepository = tileSpecRepository, + installedTilesComponentRepository = installedTilesPackageRepository, userRepository = userRepository, customTileStatePersister = customTileStatePersister, tileFactory = tileFactory, @@ -609,6 +613,40 @@ class CurrentTilesInteractorImplTest : SysuiTestCase() { assertThat((tileA as FakeQSTile).callbacks).containsExactly(callback) } + @Test + fun packageNotInstalled_customTileNotVisible() = + testScope.runTest(USER_INFO_0) { + installedTilesPackageRepository.setInstalledPackagesForUser(USER_INFO_0.id, emptySet()) + + val tiles by collectLastValue(underTest.currentTiles) + + val specs = listOf(TileSpec.create("a"), CUSTOM_TILE_SPEC) + tileSpecRepository.setTiles(USER_INFO_0.id, specs) + + assertThat(tiles!!.size).isEqualTo(1) + assertThat(tiles!![0].spec).isEqualTo(specs[0]) + } + + @Test + fun packageInstalledLater_customTileAdded() = + testScope.runTest(USER_INFO_0) { + installedTilesPackageRepository.setInstalledPackagesForUser(USER_INFO_0.id, emptySet()) + + val tiles by collectLastValue(underTest.currentTiles) + val specs = listOf(TileSpec.create("a"), CUSTOM_TILE_SPEC, TileSpec.create("b")) + tileSpecRepository.setTiles(USER_INFO_0.id, specs) + + assertThat(tiles!!.size).isEqualTo(2) + + installedTilesPackageRepository.setInstalledPackagesForUser( + USER_INFO_0.id, + setOf(TEST_COMPONENT) + ) + + assertThat(tiles!!.size).isEqualTo(3) + assertThat(tiles!![1].spec).isEqualTo(CUSTOM_TILE_SPEC) + } + private fun QSTile.State.fillIn(state: Int, label: CharSequence, secondaryLabel: CharSequence) { this.state = state this.label = label @@ -654,6 +692,7 @@ class CurrentTilesInteractorImplTest : SysuiTestCase() { private suspend fun switchUser(user: UserInfo) { setUserTracker(user.id) + installedTilesPackageRepository.setInstalledPackagesForUser(user.id, setOf(TEST_COMPONENT)) userRepository.setSelectedUserInfo(user) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/PackageManagerExtComponentEnabledTest.kt b/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/PackageManagerExtComponentEnabledTest.kt new file mode 100644 index 0000000000000..2013bb0a547ed --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/PackageManagerExtComponentEnabledTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2023 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.systemui.util.kotlin + +import android.content.ComponentName +import android.content.pm.ComponentInfo +import android.content.pm.PackageManager +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(Parameterized::class) +internal class PackageManagerExtComponentEnabledTest(private val testCase: TestCase) : + SysuiTestCase() { + + @Mock private lateinit var packageManager: PackageManager + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + } + + @Test + fun testComponentActuallyEnabled() { + whenever(packageManager.getComponentEnabledSetting(TEST_COMPONENT)) + .thenReturn(testCase.componentEnabledSetting) + val componentInfo = + mock() { + whenever(isEnabled).thenReturn(testCase.componentIsEnabled) + whenever(componentName).thenReturn(TEST_COMPONENT) + } + + assertThat(packageManager.isComponentActuallyEnabled(componentInfo)) + .isEqualTo(testCase.expected) + } + + internal data class TestCase( + @PackageManager.EnabledState val componentEnabledSetting: Int, + val componentIsEnabled: Boolean, + val expected: Boolean, + ) { + override fun toString(): String { + return "WHEN(" + + "componentIsEnabled = $componentIsEnabled, " + + "componentEnabledSetting = ${enabledStateToString()}) then " + + "EXPECTED = $expected" + } + + private fun enabledStateToString() = + when (componentEnabledSetting) { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> "STATE_DEFAULT" + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> "STATE_DISABLED" + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> { + "STATE_DISABLED_UNTIL_USED" + } + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER -> "STATE_DISABLED_USER" + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> "STATE_ENABLED" + else -> "INVALID STATE" + } + } + + companion object { + @Parameters(name = "{0}") @JvmStatic fun data(): Collection = testData + + private val testDataComponentIsEnabled = + listOf( + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + componentIsEnabled = true, + expected = true, + ), + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, + componentIsEnabled = true, + expected = false, + ), + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + componentIsEnabled = true, + expected = false, + ), + TestCase( + componentEnabledSetting = + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED, + componentIsEnabled = true, + expected = false, + ), + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, + componentIsEnabled = true, + expected = true, + ), + ) + + private val testDataComponentIsDisabled = + listOf( + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + componentIsEnabled = false, + expected = true, + ), + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, + componentIsEnabled = false, + expected = false, + ), + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + componentIsEnabled = false, + expected = false, + ), + TestCase( + componentEnabledSetting = + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED, + componentIsEnabled = false, + expected = false, + ), + TestCase( + componentEnabledSetting = PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, + componentIsEnabled = false, + expected = false, + ), + ) + + private val testData = testDataComponentIsDisabled + testDataComponentIsEnabled + + private val TEST_COMPONENT = ComponentName("pkg", "cls") + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/pipeline/data/repository/FakeInstalledTilesComponentRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/pipeline/data/repository/FakeInstalledTilesComponentRepository.kt new file mode 100644 index 0000000000000..ff6b7d083df7c --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/pipeline/data/repository/FakeInstalledTilesComponentRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 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.systemui.qs.pipeline.data.repository + +import android.content.ComponentName +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeInstalledTilesComponentRepository : InstalledTilesComponentRepository { + + private val installedComponentsPerUser = + mutableMapOf>>() + + override fun getInstalledTilesComponents(userId: Int): Flow> { + return getFlow(userId).asStateFlow() + } + + fun setInstalledPackagesForUser(userId: Int, components: Set) { + getFlow(userId).value = components + } + + private fun getFlow(userId: Int): MutableStateFlow> = + installedComponentsPerUser.getOrPut(userId) { MutableStateFlow(emptySet()) } +}