diff options
11 files changed, 861 insertions, 113 deletions
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 897b0e73dca0..5d028307a62d 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<State> 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 a066242fd96b..d7ae575724dd 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 @@ -45,6 +47,11 @@ abstract class QSPipelineModule { ): CurrentTilesInteractor @Binds + abstract fun provideInstalledTilesPackageRepository( + impl: InstalledTilesComponentRepositoryImpl + ): InstalledTilesComponentRepository + + @Binds @IntoMap @ClassKey(PrototypeCoreStartable::class) abstract fun providePrototypeCoreStartable(startable: PrototypeCoreStartable): CoreStartable 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 000000000000..498f403e8c7a --- /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<Set<ComponentName>> +} + +@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<Set<ComponentName>> = + 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<ComponentName> { + 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 3b2362f2b326..a162d113a3b2 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<TileSpec>) { - if (tiles.all { it == TileSpec.Invalid }) { - return - } - val tilesList = loadTiles(userId).toMutableList() - if (tilesList.removeAll(tiles)) { - storeTiles(userId, tilesList.toList()) + override suspend fun removeTiles(userId: Int, tiles: Collection<TileSpec>) = + mutex.withLock { + if (tiles.all { it == TileSpec.Invalid }) { + return + } + val tilesList = loadTiles(userId).toMutableList() + if (tilesList.removeAll(tiles)) { + storeTiles(userId, tilesList.toList()) + } } - } - override suspend fun setTiles(userId: Int, tiles: List<TileSpec>) { - val filtered = tiles.filter { it != TileSpec.Invalid } - if (filtered.isNotEmpty()) { - storeTiles(userId, filtered) + override suspend fun setTiles(userId: Int, tiles: List<TileSpec>) = + mutex.withLock { + val filtered = tiles.filter { it != TileSpec.Invalid } + if (filtered.isNotEmpty()) { + storeTiles(userId, filtered) + } } - } private suspend fun loadTiles(@UserIdInt forUser: Int): List<TileSpec> { 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 c579f5c3061c..ff881f767b87 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<List<TileModel>> = _currentSpecsAndTiles.asStateFlow() // This variable should only be accessed inside the collect of `startTileCollection`. - private val specsToTiles = mutableMapOf<TileSpec, QSTile>() + private val specsToTiles = mutableMapOf<TileSpec, TileOrNotInstalled>() 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<TileSpec, QSTile>() - - newTileList.forEach { tileSpec -> - if (tileSpec !in newTileMap) { - val newTile = - if (tileSpec in specsToTiles) { - processExistingTile( - tileSpec, - specsToTiles.getValue(tileSpec), - userChanged, - newUser - ) - ?: createTile(tileSpec) + } + + 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 + + // 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<TileSpec, TileOrNotInstalled>() + + 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<TileSpec>, + ) + + private data class Data( + val oldData: UserAndTiles, + val newData: UserAndTiles, + val installedComponents: Set<ComponentName>, + ) } 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 ff7d2068bc4e..8318ec99e530 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<TileSpec>, 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 000000000000..891ee0cf66d7 --- /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 000000000000..18f3837a7d36 --- /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<BroadcastReceiver> + + 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<ResolveInfoFlags>(), 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<IntentFilter>() + + 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<ResolveInfoFlags> { flags -> flags?.value == FLAGS.value } + private fun matchIntent() = argThat<Intent> { 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 426ff670802f..e7ad4896810b 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 000000000000..2013bb0a547e --- /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<ComponentInfo>() { + 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<TestCase> = 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 000000000000..ff6b7d083df7 --- /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<Int, MutableStateFlow<Set<ComponentName>>>() + + override fun getInstalledTilesComponents(userId: Int): Flow<Set<ComponentName>> { + return getFlow(userId).asStateFlow() + } + + fun setInstalledPackagesForUser(userId: Int, components: Set<ComponentName>) { + getFlow(userId).value = components + } + + private fun getFlow(userId: Int): MutableStateFlow<Set<ComponentName>> = + installedComponentsPerUser.getOrPut(userId) { MutableStateFlow(emptySet()) } +} |