Add a repository to get an icon and a label for the custom tile
Test: atest CustomTileDefaultsRepositoryTest
Flag: LEGACY QS_PIPELINE_NEW_TILES DISABLED
Bug: 301055700
Change-Id: I3316421971795933d2d51df124771452c99027a4
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index f78811f..89c6ecc 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -365,6 +365,7 @@
"tests/src/com/android/systemui/qs/pipeline/shared/TileSpecTest.kt",
"tests/src/com/android/systemui/qs/tiles/base/**/*.kt",
"tests/src/com/android/systemui/qs/tiles/viewmodel/**/*.kt",
+ "tests/src/com/android/systemui/qs/tiles/impl/**/*.kt",
/* Authentication */
"tests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt",
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/entity/CustomTileDefaults.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/entity/CustomTileDefaults.kt
new file mode 100644
index 0000000..dfeb65b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/entity/CustomTileDefaults.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.tiles.impl.custom.data.entity
+
+import android.graphics.drawable.Icon
+
+sealed interface CustomTileDefaults {
+
+ data object Error : CustomTileDefaults
+ data class Result(
+ val icon: Icon,
+ val label: CharSequence,
+ ) : CustomTileDefaults
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileDefaultsRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileDefaultsRepository.kt
new file mode 100644
index 0000000..1546ec2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileDefaultsRepository.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.qs.tiles.impl.custom.data.repository
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.graphics.drawable.Icon
+import android.os.UserHandle
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.qs.tiles.impl.custom.data.entity.CustomTileDefaults
+import com.android.systemui.qs.tiles.impl.di.QSTileScope
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.withContext
+
+/** Gets a label and an icon for a custom tile based on its package. */
+interface CustomTileDefaultsRepository {
+
+ /**
+ * Returns [CustomTileDefaults] for a specified [user]. An updated value may be emitted as a
+ * response for [requestNewDefaults].
+ *
+ * @see requestNewDefaults
+ */
+ fun defaults(user: UserHandle): Flow<CustomTileDefaults>
+
+ /**
+ * Requests the new default from the [PackageManager]. The result is cached until the input of
+ * this method changes or [force] == true is passed.
+ *
+ * Listen to [defaults] to get the loaded result
+ */
+ fun requestNewDefaults(
+ user: UserHandle,
+ componentName: ComponentName,
+ force: Boolean = false,
+ )
+}
+
+@QSTileScope
+class CustomTileDefaultsRepositoryImpl
+@Inject
+constructor(
+ private val context: Context,
+ @Application applicationScope: CoroutineScope,
+ @Background private val backgroundDispatcher: CoroutineDispatcher,
+) : CustomTileDefaultsRepository {
+
+ private val defaultsRequests =
+ MutableSharedFlow<DefaultsRequest>(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
+ private val defaults: SharedFlow<DefaultsResult> =
+ defaultsRequests
+ .distinctUntilChanged { old, new ->
+ if (new.force) {
+ // force update should always pass
+ false
+ } else {
+ old == new
+ }
+ }
+ .map { DefaultsResult(it.user, loadDefaults(it.user, it.componentName)) }
+ .shareIn(applicationScope, SharingStarted.WhileSubscribed(), replay = 1)
+
+ override fun defaults(user: UserHandle): Flow<CustomTileDefaults> =
+ defaults.filter { it.user == user }.map { it.data }
+
+ override fun requestNewDefaults(
+ user: UserHandle,
+ componentName: ComponentName,
+ force: Boolean,
+ ) {
+ defaultsRequests.tryEmit(DefaultsRequest(user, componentName, force))
+ }
+
+ private suspend fun loadDefaults(
+ user: UserHandle,
+ componentName: ComponentName
+ ): CustomTileDefaults =
+ withContext(backgroundDispatcher) {
+ try {
+ val userContext = context.createContextAsUser(user, 0)
+ val info = componentName.getServiceInfo(userContext.packageManager)
+
+ val iconRes = if (info.icon == NO_ICON_RES) info.applicationInfo.icon else info.icon
+ if (iconRes == NO_ICON_RES) {
+ return@withContext CustomTileDefaults.Error
+ }
+
+ CustomTileDefaults.Result(
+ Icon.createWithResource(componentName.packageName, iconRes),
+ info.loadLabel(userContext.packageManager)
+ )
+ } catch (e: PackageManager.NameNotFoundException) {
+ CustomTileDefaults.Error
+ }
+ }
+
+ private fun ComponentName.getServiceInfo(
+ packageManager: PackageManager,
+ ): ServiceInfo {
+ val isSystemApp = packageManager.getApplicationInfo(packageName, 0).isSystemApp
+ var flags =
+ (PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE)
+ if (isSystemApp) {
+ flags = flags or PackageManager.MATCH_DISABLED_COMPONENTS
+ }
+ return packageManager.getServiceInfo(this, flags)
+ }
+
+ private data class DefaultsRequest(
+ val user: UserHandle,
+ val componentName: ComponentName,
+ val force: Boolean = false,
+ )
+
+ private data class DefaultsResult(val user: UserHandle, val data: CustomTileDefaults)
+
+ private companion object {
+
+ const val NO_ICON_RES = 0
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/di/CustomTileModule.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/di/CustomTileModule.kt
index ccff8af..482bf9b 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/di/CustomTileModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/di/CustomTileModule.kt
@@ -23,6 +23,8 @@
import com.android.systemui.qs.tiles.impl.custom.CustomTileInteractor
import com.android.systemui.qs.tiles.impl.custom.CustomTileMapper
import com.android.systemui.qs.tiles.impl.custom.CustomTileUserActionInteractor
+import com.android.systemui.qs.tiles.impl.custom.data.repository.CustomTileDefaultsRepository
+import com.android.systemui.qs.tiles.impl.custom.data.repository.CustomTileDefaultsRepositoryImpl
import com.android.systemui.qs.tiles.impl.custom.di.bound.CustomTileBoundComponent
import dagger.Binds
import dagger.Module
@@ -43,4 +45,9 @@
@Binds
fun bindMapper(customTileMapper: CustomTileMapper): QSTileDataToStateMapper<CustomTileData>
+
+ @Binds
+ fun bindCustomTileDefaultsRepository(
+ impl: CustomTileDefaultsRepositoryImpl
+ ): CustomTileDefaultsRepository
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/CustomTileDefaultsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/CustomTileDefaultsRepositoryTest.kt
new file mode 100644
index 0000000..89ba69f
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/CustomTileDefaultsRepositoryTest.kt
@@ -0,0 +1,259 @@
+/*
+ * 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.tiles.impl.custom
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.os.UserHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.qs.tiles.impl.custom.data.entity.CustomTileDefaults
+import com.android.systemui.qs.tiles.impl.custom.data.repository.CustomTileDefaultsRepository
+import com.android.systemui.qs.tiles.impl.custom.data.repository.CustomTileDefaultsRepositoryImpl
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.mockito.whenever
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+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.Mock
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class CustomTileDefaultsRepositoryTest : SysuiTestCase() {
+
+ @Mock private lateinit var sysuiContext: Context
+ @Mock private lateinit var user1Context: Context
+ @Mock private lateinit var user2Context: Context
+ @Mock private lateinit var packageManager1: PackageManager
+ @Mock private lateinit var packageManager2: PackageManager
+
+ private val testDispatcher = StandardTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ private lateinit var underTest: CustomTileDefaultsRepository
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+
+ whenever(sysuiContext.createContextAsUser(eq(USER_1), any())).thenReturn(user1Context)
+ whenever(user1Context.packageManager).thenReturn(packageManager1)
+ packageManager1.setupApp1()
+
+ whenever(sysuiContext.createContextAsUser(eq(USER_2), any())).thenReturn(user2Context)
+ whenever(user2Context.packageManager).thenReturn(packageManager2)
+ packageManager2.setupApp2()
+
+ underTest =
+ CustomTileDefaultsRepositoryImpl(
+ sysuiContext,
+ testScope.backgroundScope,
+ testDispatcher,
+ )
+ }
+
+ @Test
+ fun regularRequestingEmitsTheNewDefault() =
+ testScope.runTest {
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+
+ runCurrent()
+
+ val default = underTest.defaults(USER_1).first() as CustomTileDefaults.Result
+ assertThat(default.label).isEqualTo(APP_LABEL_1)
+ assertThat(default.icon.resId).isEqualTo(SERVICE_ICON_1)
+ assertThat(default.icon.resPackage).isEqualTo(COMPONENT_NAME_1.packageName)
+ }
+
+ @Test
+ fun requestingSystemAppEmitsTheNewDefault() =
+ testScope.runTest {
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+
+ runCurrent()
+
+ val default = underTest.defaults(USER_1).first() as CustomTileDefaults.Result
+ assertThat(default.label).isEqualTo(APP_LABEL_1)
+ assertThat(default.icon.resId).isEqualTo(SERVICE_ICON_1)
+ assertThat(default.icon.resPackage).isEqualTo(COMPONENT_NAME_1.packageName)
+ }
+
+ @Test
+ fun requestingForcesTheNewEmit() =
+ testScope.runTest {
+ val defaults = mutableListOf<CustomTileDefaults.Result>()
+ backgroundScope.launch {
+ underTest
+ .defaults(USER_1)
+ .map { it as CustomTileDefaults.Result }
+ .collect { defaults.add(it) }
+ }
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+ // the same request should be skipped. This leads to 2 result in assertions
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+ runCurrent()
+
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, true)
+ runCurrent()
+
+ assertThat(defaults).hasSize(2)
+ assertThat(defaults.last().label).isEqualTo(APP_LABEL_1)
+ assertThat(defaults.last().icon.resId).isEqualTo(SERVICE_ICON_1)
+ assertThat(defaults.last().icon.resPackage).isEqualTo(COMPONENT_NAME_1.packageName)
+ }
+
+ @Test
+ fun userChangeForcesTheNewEmit() =
+ testScope.runTest {
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+ runCurrent()
+
+ underTest.requestNewDefaults(USER_2, COMPONENT_NAME_2, false)
+ runCurrent()
+
+ val default = underTest.defaults(USER_2).first() as CustomTileDefaults.Result
+ assertThat(default.label).isEqualTo(APP_LABEL_2)
+ assertThat(default.icon.resId).isEqualTo(SERVICE_ICON_2)
+ assertThat(default.icon.resPackage).isEqualTo(COMPONENT_NAME_2.packageName)
+ }
+
+ @Test
+ fun componentNameChangeForcesTheNewEmit() =
+ testScope.runTest {
+ packageManager1.setupApp2(false)
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+ runCurrent()
+
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_2, false)
+ runCurrent()
+
+ val default = underTest.defaults(USER_1).first() as CustomTileDefaults.Result
+ assertThat(default.label).isEqualTo(APP_LABEL_2)
+ assertThat(default.icon.resId).isEqualTo(SERVICE_ICON_2)
+ assertThat(default.icon.resPackage).isEqualTo(COMPONENT_NAME_2.packageName)
+ }
+
+ @Test
+ fun noIconIsAnError() =
+ testScope.runTest {
+ packageManager1.setupApp(
+ componentName = COMPONENT_NAME_1,
+ appLabel = "",
+ serviceIcon = 0,
+ appInfoIcon = 0,
+ isSystemApp = false,
+ )
+ underTest.requestNewDefaults(USER_1, COMPONENT_NAME_1, false)
+
+ runCurrent()
+
+ assertThat(underTest.defaults(USER_1).first())
+ .isInstanceOf(CustomTileDefaults.Error::class.java)
+ }
+
+ @Test
+ fun applicationScopeIsFreedWhileNotSubscribed() =
+ testScope.runTest {
+ val listenJob = underTest.defaults(USER_1).launchIn(backgroundScope)
+ listenJob.cancel()
+ assertThat(this.coroutineContext[Job]!!.children.toList()).isEmpty()
+ }
+
+ private fun PackageManager.setupApp1(isSystemApp: Boolean = false) =
+ setupApp(
+ componentName = COMPONENT_NAME_1,
+ serviceIcon = SERVICE_ICON_1,
+ appLabel = APP_LABEL_1,
+ appInfoIcon = APP_INFO_ICON_1,
+ isSystemApp = isSystemApp,
+ )
+ private fun PackageManager.setupApp2(isSystemApp: Boolean = false) =
+ setupApp(
+ componentName = COMPONENT_NAME_2,
+ serviceIcon = SERVICE_ICON_2,
+ appLabel = APP_LABEL_2,
+ appInfoIcon = APP_INFO_ICON_2,
+ isSystemApp = isSystemApp,
+ )
+
+ private fun PackageManager.setupApp(
+ componentName: ComponentName,
+ serviceIcon: Int,
+ appLabel: CharSequence,
+ appInfoIcon: Int = serviceIcon,
+ isSystemApp: Boolean = false,
+ ) {
+ val appInfo =
+ object : ApplicationInfo() {
+ override fun isSystemApp(): Boolean = isSystemApp
+ }
+ .apply { icon = appInfoIcon }
+ whenever(getApplicationInfo(eq(componentName.packageName), any<Int>())).thenReturn(appInfo)
+
+ // set of desired flags is different for system and a regular app.
+ var serviceFlags =
+ (PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE)
+ if (isSystemApp) {
+ serviceFlags = serviceFlags or PackageManager.MATCH_DISABLED_COMPONENTS
+ }
+
+ val serviceInfo =
+ object : ServiceInfo() {
+ override fun loadLabel(pm: PackageManager): CharSequence = appLabel
+ }
+ .apply {
+ applicationInfo = appInfo
+ icon = serviceIcon
+ }
+ whenever(getServiceInfo(eq(componentName), eq(serviceFlags))).thenReturn(serviceInfo)
+ }
+
+ private companion object {
+ val USER_1 = UserHandle(1)
+ val USER_2 = UserHandle(2)
+
+ val COMPONENT_NAME_1 = ComponentName("pkg.test_1", "cls")
+ const val SERVICE_ICON_1 = 11
+ const val APP_INFO_ICON_1 = 12
+ const val APP_LABEL_1 = "app_1"
+
+ val COMPONENT_NAME_2 = ComponentName("pkg.test_2", "cls")
+ const val SERVICE_ICON_2 = 21
+ const val APP_INFO_ICON_2 = 22
+ const val APP_LABEL_2 = "app_2"
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/data/repository/FakeCustomTileDefaultsRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/data/repository/FakeCustomTileDefaultsRepository.kt
new file mode 100644
index 0000000..13910fd
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/data/repository/FakeCustomTileDefaultsRepository.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.tiles.impl.custom.data.repository
+
+import android.content.ComponentName
+import android.os.UserHandle
+import com.android.systemui.qs.tiles.impl.custom.data.entity.CustomTileDefaults
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class FakeCustomTileDefaultsRepository : CustomTileDefaultsRepository {
+
+ private val defaults: MutableMap<DefaultsKey, CustomTileDefaults> = mutableMapOf()
+ private val defaultsFlow = MutableSharedFlow<DefaultsRequest>()
+
+ private val mutableDefaultsRequests: MutableList<DefaultsRequest> = mutableListOf()
+ val defaultsRequests: List<DefaultsRequest> = mutableDefaultsRequests
+
+ override fun defaults(user: UserHandle): Flow<CustomTileDefaults> =
+ defaultsFlow
+ .distinctUntilChanged { old, new ->
+ if (new.force) {
+ false
+ } else {
+ old == new
+ }
+ }
+ .map { defaults[DefaultsKey(it.user, it.componentName)]!! }
+
+ override fun requestNewDefaults(
+ user: UserHandle,
+ componentName: ComponentName,
+ force: Boolean
+ ) {
+ val request = DefaultsRequest(user, componentName, force)
+ mutableDefaultsRequests.add(request)
+ defaultsFlow.tryEmit(request)
+ }
+
+ fun putDefaults(
+ user: UserHandle,
+ componentName: ComponentName,
+ customTileDefaults: CustomTileDefaults,
+ ) {
+ defaults[DefaultsKey(user, componentName)] = customTileDefaults
+ }
+
+ fun removeDefaults(user: UserHandle, componentName: ComponentName) {
+ defaults.remove(DefaultsKey(user, componentName))
+ }
+
+ data class DefaultsRequest(
+ val user: UserHandle,
+ val componentName: ComponentName,
+ val force: Boolean = false,
+ )
+
+ private data class DefaultsKey(val user: UserHandle, val componentName: ComponentName)
+}