diff options
13 files changed, 1016 insertions, 24 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/qs/external/CustomTileStatePersister.kt b/packages/SystemUI/src/com/android/systemui/qs/external/CustomTileStatePersister.kt index a321eef75a14..6f5dea32bd83 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/external/CustomTileStatePersister.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/external/CustomTileStatePersister.kt @@ -18,17 +18,19 @@ package com.android.systemui.qs.external import android.content.ComponentName import android.content.Context +import android.content.SharedPreferences import android.service.quicksettings.Tile import android.util.Log import com.android.internal.annotations.VisibleForTesting +import javax.inject.Inject import org.json.JSONException import org.json.JSONObject -import javax.inject.Inject data class TileServiceKey(val componentName: ComponentName, val user: Int) { private val string = "${componentName.flattenToString()}:$user" override fun toString() = string } + private const val STATE = "state" private const val LABEL = "label" private const val SUBTITLE = "subtitle" @@ -44,12 +46,7 @@ private const val STATE_DESCRIPTION = "state_description" * It persists the state from a [Tile] necessary to present the view in the same state when * retrieved, with the exception of the icon. */ -class CustomTileStatePersister @Inject constructor(context: Context) { - companion object { - private const val FILE_NAME = "custom_tiles_state" - } - - private val sharedPreferences = context.getSharedPreferences(FILE_NAME, 0) +interface CustomTileStatePersister { /** * Read the state from [SharedPreferences]. @@ -58,7 +55,31 @@ class CustomTileStatePersister @Inject constructor(context: Context) { * * Any fields that have not been saved will be set to `null` */ - fun readState(key: TileServiceKey): Tile? { + fun readState(key: TileServiceKey): Tile? + /** + * Persists the state into [SharedPreferences]. + * + * The implementation does not store fields that are `null` or icons. + */ + fun persistState(key: TileServiceKey, tile: Tile) + /** + * Removes the state for a given tile, user pair. + * + * Used when the tile is removed by the user. + */ + fun removeState(key: TileServiceKey) +} + +// TODO(b/299909989) Merge this class into into CustomTileRepository +class CustomTileStatePersisterImpl @Inject constructor(context: Context) : + CustomTileStatePersister { + companion object { + private const val FILE_NAME = "custom_tiles_state" + } + + private val sharedPreferences: SharedPreferences = context.getSharedPreferences(FILE_NAME, 0) + + override fun readState(key: TileServiceKey): Tile? { val state = sharedPreferences.getString(key.toString(), null) ?: return null return try { readTileFromString(state) @@ -68,23 +89,13 @@ class CustomTileStatePersister @Inject constructor(context: Context) { } } - /** - * Persists the state into [SharedPreferences]. - * - * The implementation does not store fields that are `null` or icons. - */ - fun persistState(key: TileServiceKey, tile: Tile) { + override fun persistState(key: TileServiceKey, tile: Tile) { val state = writeToString(tile) sharedPreferences.edit().putString(key.toString(), state).apply() } - /** - * Removes the state for a given tile, user pair. - * - * Used when the tile is removed by the user. - */ - fun removeState(key: TileServiceKey) { + override fun removeState(key: TileServiceKey) { sharedPreferences.edit().remove(key.toString()).apply() } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/di/QSTilesModule.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/di/QSTilesModule.kt index 94137c88098e..4a34276671c1 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/di/QSTilesModule.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/di/QSTilesModule.kt @@ -16,6 +16,8 @@ package com.android.systemui.qs.tiles.di +import com.android.systemui.qs.external.CustomTileStatePersister +import com.android.systemui.qs.external.CustomTileStatePersisterImpl import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandler import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandlerImpl import com.android.systemui.qs.tiles.impl.custom.di.CustomTileComponent @@ -52,4 +54,7 @@ interface QSTilesModule { fun bindQSTileIntentUserInputHandler( impl: QSTileIntentUserInputHandlerImpl ): QSTileIntentUserInputHandler + + @Binds + fun bindCustomTileStatePersister(impl: CustomTileStatePersisterImpl): CustomTileStatePersister } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/commons/TileExt.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/commons/TileExt.kt new file mode 100644 index 000000000000..869f6f321d21 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/commons/TileExt.kt @@ -0,0 +1,50 @@ +/* + * 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.commons + +import android.service.quicksettings.Tile + +fun Tile.copy(): Tile = + Tile().also { + it.icon = icon + it.label = label + it.subtitle = subtitle + it.contentDescription = contentDescription + it.stateDescription = stateDescription + it.activityLaunchForClick = activityLaunchForClick + it.state = state + } + +fun Tile.setFrom(otherTile: Tile) { + if (otherTile.icon != null) { + icon = otherTile.icon + } + if (otherTile.customLabel != null) { + label = otherTile.customLabel + } + if (otherTile.subtitle != null) { + subtitle = otherTile.subtitle + } + if (otherTile.contentDescription != null) { + contentDescription = otherTile.contentDescription + } + if (otherTile.stateDescription != null) { + stateDescription = otherTile.stateDescription + } + activityLaunchForClick = otherTile.activityLaunchForClick + state = otherTile.state +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileRepository.kt new file mode 100644 index 000000000000..ca5302e13545 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileRepository.kt @@ -0,0 +1,196 @@ +/* + * 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.graphics.drawable.Icon +import android.os.UserHandle +import android.service.quicksettings.Tile +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.qs.external.CustomTileStatePersister +import com.android.systemui.qs.external.TileServiceKey +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.impl.custom.commons.copy +import com.android.systemui.qs.tiles.impl.custom.commons.setFrom +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 kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Repository store the [Tile] associated with the custom tile. It lives on [QSTileScope] which + * allows it to survive service rebinding. Given that, it provides the last received state when + * connected again. + */ +interface CustomTileRepository { + + /** + * Restores the [Tile] if it's [isPersistable]. Restored [Tile] will be available via [getTile] + * (but there is no guarantee that restoration is synchronous) and emitted in [getTiles] for a + * corresponding [user]. + */ + suspend fun restoreForTheUserIfNeeded(user: UserHandle, isPersistable: Boolean) + + /** Returns [Tile] updates for a [user]. */ + fun getTiles(user: UserHandle): Flow<Tile> + + /** + * Return current [Tile] for a [user] or null if the [user] doesn't match currently cached one. + * Suspending until [getTiles] returns something is a way to wait for this to become available. + * + * @throws IllegalStateException when there is no current tile. + */ + fun getTile(user: UserHandle): Tile? + + /** + * Updates tile with the non-null values from [newTile]. Overwrites the current cache when + * [user] differs from the cached one. [isPersistable] tile will be persisted to be possibly + * loaded when the [restoreForTheUserIfNeeded]. + */ + suspend fun updateWithTile( + user: UserHandle, + newTile: Tile, + isPersistable: Boolean, + ) + + /** + * Updates tile with the values from [defaults]. Overwrites the current cache when [user] + * differs from the cached one. [isPersistable] tile will be persisted to be possibly loaded + * when the [restoreForTheUserIfNeeded]. + */ + suspend fun updateWithDefaults( + user: UserHandle, + defaults: CustomTileDefaults, + isPersistable: Boolean, + ) +} + +@QSTileScope +class CustomTileRepositoryImpl +@Inject +constructor( + private val tileSpec: TileSpec.CustomTileSpec, + private val customTileStatePersister: CustomTileStatePersister, + @Background private val backgroundContext: CoroutineContext, +) : CustomTileRepository { + + private val tileUpdateMutex = Mutex() + private val tileWithUserState = + MutableSharedFlow<TileWithUser>(onBufferOverflow = BufferOverflow.DROP_OLDEST, replay = 1) + + override suspend fun restoreForTheUserIfNeeded(user: UserHandle, isPersistable: Boolean) { + if (isPersistable && getCurrentTileWithUser()?.user != user) { + withContext(backgroundContext) { + customTileStatePersister.readState(user.getKey())?.let { + updateWithTile( + user, + it, + true, + ) + } + } + } + } + + override fun getTiles(user: UserHandle): Flow<Tile> = + tileWithUserState.filter { it.user == user }.map { it.tile } + + override fun getTile(user: UserHandle): Tile? { + val tileWithUser = + getCurrentTileWithUser() ?: throw IllegalStateException("Tile is not set") + return if (tileWithUser.user == user) { + tileWithUser.tile + } else { + null + } + } + + override suspend fun updateWithTile( + user: UserHandle, + newTile: Tile, + isPersistable: Boolean, + ) = updateTile(user, isPersistable) { setFrom(newTile) } + + override suspend fun updateWithDefaults( + user: UserHandle, + defaults: CustomTileDefaults, + isPersistable: Boolean, + ) { + if (defaults is CustomTileDefaults.Result) { + updateTile(user, isPersistable) { + // Update the icon if it's not set or is the default icon. + val updateIcon = (icon == null || icon.isResourceEqual(defaults.icon)) + if (updateIcon) { + icon = defaults.icon + } + setDefaultLabel(defaults.label) + } + } + } + + private suspend fun updateTile( + user: UserHandle, + isPersistable: Boolean, + update: Tile.() -> Unit + ): Unit = + tileUpdateMutex.withLock { + val currentTileWithUser = getCurrentTileWithUser() + val tileToUpdate = + if (currentTileWithUser?.user == user) { + currentTileWithUser.tile.copy() + } else { + Tile() + } + tileToUpdate.update() + if (isPersistable) { + withContext(backgroundContext) { + customTileStatePersister.persistState(user.getKey(), tileToUpdate) + } + } + tileWithUserState.tryEmit(TileWithUser(user, tileToUpdate)) + } + + private fun getCurrentTileWithUser(): TileWithUser? = tileWithUserState.replayCache.lastOrNull() + + /** Compare two icons, only works for resources. */ + private fun Icon.isResourceEqual(icon2: Icon?): Boolean { + if (icon2 == null) { + return false + } + if (this === icon2) { + return true + } + if (type != Icon.TYPE_RESOURCE || icon2.type != Icon.TYPE_RESOURCE) { + return false + } + if (resId != icon2.resId) { + return false + } + return resPackage == icon2.resPackage + } + + private fun UserHandle.getKey() = TileServiceKey(tileSpec.componentName, this.identifier) + + private data class TileWithUser(val user: UserHandle, val tile: Tile) +} 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 83767aa9d444..d956fdebcd32 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 @@ -24,6 +24,8 @@ 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.data.repository.CustomTileRepository +import com.android.systemui.qs.tiles.impl.custom.data.repository.CustomTileRepositoryImpl import com.android.systemui.qs.tiles.impl.custom.di.bound.CustomTileBoundComponent import com.android.systemui.qs.tiles.impl.custom.domain.entity.CustomTileDataModel import dagger.Binds @@ -50,4 +52,6 @@ interface CustomTileModule { fun bindCustomTileDefaultsRepository( impl: CustomTileDefaultsRepositoryImpl ): CustomTileDefaultsRepository + + @Binds fun bindCustomTileRepository(impl: CustomTileRepositoryImpl): CustomTileRepository } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/domain/interactor/CustomTileInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/domain/interactor/CustomTileInteractor.kt new file mode 100644 index 000000000000..351bba538463 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/custom/domain/interactor/CustomTileInteractor.kt @@ -0,0 +1,112 @@ +/* + * 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.domain.interactor + +import android.os.UserHandle +import android.service.quicksettings.Tile +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.qs.external.TileServiceManager +import com.android.systemui.qs.tiles.impl.custom.data.repository.CustomTileDefaultsRepository +import com.android.systemui.qs.tiles.impl.custom.data.repository.CustomTileRepository +import com.android.systemui.qs.tiles.impl.custom.di.bound.CustomTileBoundScope +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** Manages updates of the [Tile] assigned for the current custom tile. */ +@CustomTileBoundScope +class CustomTileInteractor +@Inject +constructor( + private val user: UserHandle, + private val defaultsRepository: CustomTileDefaultsRepository, + private val customTileRepository: CustomTileRepository, + private val tileServiceManager: TileServiceManager, + @CustomTileBoundScope private val boundScope: CoroutineScope, + @Background private val backgroundContext: CoroutineContext, +) { + + private val tileUpdates = + MutableSharedFlow<Tile>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** [Tile] updates. [updateTile] to emit a new one. */ + val tiles: Flow<Tile> + get() = customTileRepository.getTiles(user) + + /** + * Current [Tile] + * + * @throws IllegalStateException when the repository stores a tile for another user. This means + * the tile hasn't been updated for the current user. Can happen when this is accessed before + * [init] returns. + */ + val tile: Tile + get() = + customTileRepository.getTile(user) + ?: throw IllegalStateException("Attempt to get a tile for a wrong user") + + /** + * Initializes the repository for the current user. Suspends until it's safe to call [tile] + * which needs at least one of the following: + * - defaults are loaded; + * - receive tile update in [updateTile]; + * - restoration happened for a persisted tile. + */ + suspend fun init() { + launchUpdates() + customTileRepository.restoreForTheUserIfNeeded(user, tileServiceManager.isActiveTile) + // Suspend to make sure it gets the tile from one of the sources: restoration, defaults, or + // tile update. + customTileRepository.getTiles(user).firstOrNull() + } + + private fun launchUpdates() { + tileUpdates + .onEach { + customTileRepository.updateWithTile( + user, + it, + tileServiceManager.isActiveTile, + ) + } + .flowOn(backgroundContext) + .launchIn(boundScope) + defaultsRepository + .defaults(user) + .onEach { + customTileRepository.updateWithDefaults( + user, + it, + tileServiceManager.isActiveTile, + ) + } + .flowOn(backgroundContext) + .launchIn(boundScope) + } + + /** Updates current [Tile]. Emits a new event in [tiles]. */ + fun updateTile(newTile: Tile) { + tileUpdates.tryEmit(newTile) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileStatePersisterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileStatePersisterTest.kt index a9f8ea0194c1..81d02b8043b9 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileStatePersisterTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/external/CustomTileStatePersisterTest.kt @@ -85,7 +85,7 @@ class CustomTileStatePersisterTest : SysuiTestCase() { `when`(sharedPreferences.edit()).thenReturn(editor) tile = Tile() - customTileStatePersister = CustomTileStatePersister(mockContext) + customTileStatePersister = CustomTileStatePersisterImpl(mockContext) } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileRepositoryTest.kt new file mode 100644 index 000000000000..cf076c557765 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/data/repository/CustomTileRepositoryTest.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.data.repository + +import android.content.ComponentName +import android.graphics.drawable.Icon +import android.os.UserHandle +import android.service.quicksettings.Tile +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectValues +import com.android.systemui.qs.external.FakeCustomTileStatePersister +import com.android.systemui.qs.external.TileServiceKey +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.impl.custom.TileSubject.Companion.assertThat +import com.android.systemui.qs.tiles.impl.custom.commons.copy +import com.android.systemui.qs.tiles.impl.custom.data.entity.CustomTileDefaults +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class CustomTileRepositoryTest : SysuiTestCase() { + + private val testScope = TestScope() + + private val persister = FakeCustomTileStatePersister() + + private val underTest: CustomTileRepository = + CustomTileRepositoryImpl( + TileSpec.create(TEST_COMPONENT), + persister, + testScope.testScheduler, + ) + + @Test + fun persistableTileIsRestoredForUser() = + testScope.runTest { + persister.persistState(TEST_TILE_KEY_1, TEST_TILE_1) + persister.persistState(TEST_TILE_KEY_2, TEST_TILE_2) + + underTest.restoreForTheUserIfNeeded(TEST_USER_1, true) + runCurrent() + + assertThat(underTest.getTile(TEST_USER_1)).isEqualTo(TEST_TILE_1) + assertThat(underTest.getTiles(TEST_USER_1).first()).isEqualTo(TEST_TILE_1) + } + + @Test + fun notPersistableTileIsNotRestored() = + testScope.runTest { + persister.persistState(TEST_TILE_KEY_1, TEST_TILE_1) + val tiles = collectValues(underTest.getTiles(TEST_USER_1)) + + underTest.restoreForTheUserIfNeeded(TEST_USER_1, false) + runCurrent() + + assertThat(tiles()).isEmpty() + } + + @Test + fun emptyPersistedStateIsHandled() = + testScope.runTest { + val tiles = collectValues(underTest.getTiles(TEST_USER_1)) + + underTest.restoreForTheUserIfNeeded(TEST_USER_1, true) + runCurrent() + + assertThat(tiles()).isEmpty() + } + + @Test + fun updatingWithPersistableTilePersists() = + testScope.runTest { + underTest.updateWithTile(TEST_USER_1, TEST_TILE_1, true) + runCurrent() + + assertThat(persister.readState(TEST_TILE_KEY_1)).isEqualTo(TEST_TILE_1) + } + + @Test + fun updatingWithNotPersistableTileDoesntPersist() = + testScope.runTest { + underTest.updateWithTile(TEST_USER_1, TEST_TILE_1, false) + runCurrent() + + assertThat(persister.readState(TEST_TILE_KEY_1)).isNull() + } + + @Test + fun updateWithTileEmits() = + testScope.runTest { + underTest.updateWithTile(TEST_USER_1, TEST_TILE_1, true) + runCurrent() + + assertThat(underTest.getTiles(TEST_USER_1).first()).isEqualTo(TEST_TILE_1) + assertThat(underTest.getTile(TEST_USER_1)).isEqualTo(TEST_TILE_1) + } + + @Test + fun updatingPeristableWithDefaultsPersists() = + testScope.runTest { + underTest.updateWithDefaults(TEST_USER_1, TEST_DEFAULTS_1, true) + runCurrent() + + assertThat(persister.readState(TEST_TILE_KEY_1)).isEqualTo(TEST_TILE_1) + } + + @Test + fun updatingNotPersistableWithDefaultsDoesntPersist() = + testScope.runTest { + underTest.updateWithDefaults(TEST_USER_1, TEST_DEFAULTS_1, false) + runCurrent() + + assertThat(persister.readState(TEST_TILE_KEY_1)).isNull() + } + + @Test + fun updatingPeristableWithErrorDefaultsDoesntPersist() = + testScope.runTest { + underTest.updateWithDefaults(TEST_USER_1, CustomTileDefaults.Error, true) + runCurrent() + + assertThat(persister.readState(TEST_TILE_KEY_1)).isNull() + } + + @Test + fun updateWithDefaultsEmits() = + testScope.runTest { + underTest.updateWithDefaults(TEST_USER_1, TEST_DEFAULTS_1, true) + runCurrent() + + assertThat(underTest.getTiles(TEST_USER_1).first()).isEqualTo(TEST_TILE_1) + assertThat(underTest.getTile(TEST_USER_1)).isEqualTo(TEST_TILE_1) + } + + @Test + fun getTileForAnotherUserReturnsNull() = + testScope.runTest { + underTest.updateWithTile(TEST_USER_1, TEST_TILE_1, true) + runCurrent() + + assertThat(underTest.getTile(TEST_USER_2)).isNull() + } + + @Test + fun getTilesForAnotherUserEmpty() = + testScope.runTest { + val tiles = collectValues(underTest.getTiles(TEST_USER_2)) + + underTest.updateWithTile(TEST_USER_1, TEST_TILE_1, true) + runCurrent() + + assertThat(tiles()).isEmpty() + } + + @Test + fun updatingWithTileForTheSameUserAddsData() = + testScope.runTest { + underTest.updateWithTile(TEST_USER_1, TEST_TILE_1, true) + runCurrent() + + underTest.updateWithTile(TEST_USER_1, Tile().apply { subtitle = "test_subtitle" }, true) + runCurrent() + + val expectedTile = TEST_TILE_1.copy().apply { subtitle = "test_subtitle" } + assertThat(underTest.getTile(TEST_USER_1)).isEqualTo(expectedTile) + assertThat(underTest.getTiles(TEST_USER_1).first()).isEqualTo(expectedTile) + } + + @Test + fun updatingWithTileForAnotherUserOverridesTile() = + testScope.runTest { + underTest.updateWithTile(TEST_USER_1, TEST_TILE_1, true) + runCurrent() + + val tiles = collectValues(underTest.getTiles(TEST_USER_2)) + underTest.updateWithTile(TEST_USER_2, TEST_TILE_2, true) + runCurrent() + + assertThat(underTest.getTile(TEST_USER_2)).isEqualTo(TEST_TILE_2) + assertThat(tiles()).hasSize(1) + assertThat(tiles().last()).isEqualTo(TEST_TILE_2) + } + + @Test + fun updatingWithDefaultsForTheSameUserAddsData() = + testScope.runTest { + underTest.updateWithTile(TEST_USER_1, Tile().apply { subtitle = "test_subtitle" }, true) + runCurrent() + + underTest.updateWithDefaults(TEST_USER_1, TEST_DEFAULTS_1, true) + runCurrent() + + val expectedTile = TEST_TILE_1.copy().apply { subtitle = "test_subtitle" } + assertThat(underTest.getTile(TEST_USER_1)).isEqualTo(expectedTile) + assertThat(underTest.getTiles(TEST_USER_1).first()).isEqualTo(expectedTile) + } + + @Test + fun updatingWithDefaultsForAnotherUserOverridesTile() = + testScope.runTest { + underTest.updateWithDefaults(TEST_USER_1, TEST_DEFAULTS_1, true) + runCurrent() + + val tiles = collectValues(underTest.getTiles(TEST_USER_2)) + underTest.updateWithDefaults(TEST_USER_2, TEST_DEFAULTS_2, true) + runCurrent() + + assertThat(underTest.getTile(TEST_USER_2)).isEqualTo(TEST_TILE_2) + assertThat(tiles()).hasSize(1) + assertThat(tiles().last()).isEqualTo(TEST_TILE_2) + } + + private companion object { + + val TEST_COMPONENT = ComponentName("test.pkg", "test.cls") + + val TEST_USER_1 = UserHandle.of(1)!! + val TEST_TILE_1 = + Tile().apply { + label = "test_tile_1" + icon = Icon.createWithContentUri("file://test_1") + } + val TEST_TILE_KEY_1 = TileServiceKey(TEST_COMPONENT, TEST_USER_1.identifier) + val TEST_DEFAULTS_1 = CustomTileDefaults.Result(TEST_TILE_1.icon, TEST_TILE_1.label) + + val TEST_USER_2 = UserHandle.of(2)!! + val TEST_TILE_2 = + Tile().apply { + label = "test_tile_2" + icon = Icon.createWithContentUri("file://test_2") + } + val TEST_TILE_KEY_2 = TileServiceKey(TEST_COMPONENT, TEST_USER_2.identifier) + val TEST_DEFAULTS_2 = CustomTileDefaults.Result(TEST_TILE_2.icon, TEST_TILE_2.label) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/domain/interactor/CustomTileInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/domain/interactor/CustomTileInteractorTest.kt new file mode 100644 index 000000000000..eebb145ef384 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/impl/custom/domain/interactor/CustomTileInteractorTest.kt @@ -0,0 +1,187 @@ +/* + * 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.domain.interactor + +import android.content.ComponentName +import android.graphics.drawable.Icon +import android.os.UserHandle +import android.service.quicksettings.Tile +import android.text.format.DateUtils +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectValues +import com.android.systemui.qs.external.FakeCustomTileStatePersister +import com.android.systemui.qs.external.TileServiceKey +import com.android.systemui.qs.external.TileServiceManager +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.impl.custom.TileSubject.Companion.assertThat +import com.android.systemui.qs.tiles.impl.custom.data.entity.CustomTileDefaults +import com.android.systemui.qs.tiles.impl.custom.data.repository.FakeCustomTileDefaultsRepository +import com.android.systemui.qs.tiles.impl.custom.data.repository.FakeCustomTileRepository +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +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 CustomTileInteractorTest : SysuiTestCase() { + + @Mock private lateinit var tileServiceManager: TileServiceManager + + private val testScope = TestScope() + + private val defaultsRepository = FakeCustomTileDefaultsRepository() + private val customTileStatePersister = FakeCustomTileStatePersister() + private val customTileRepository = + FakeCustomTileRepository( + TEST_TILE_SPEC, + customTileStatePersister, + testScope.testScheduler, + ) + + private lateinit var underTest: CustomTileInteractor + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + underTest = + CustomTileInteractor( + TEST_USER, + defaultsRepository, + customTileRepository, + tileServiceManager, + testScope.backgroundScope, + testScope.testScheduler, + ) + } + + @Test + fun activeTileIsAvailableAfterRestored() = + testScope.runTest { + whenever(tileServiceManager.isActiveTile).thenReturn(true) + customTileStatePersister.persistState( + TileServiceKey(TEST_COMPONENT, TEST_USER.identifier), + TEST_TILE, + ) + + underTest.init() + + assertThat(underTest.tile).isEqualTo(TEST_TILE) + assertThat(underTest.tiles.first()).isEqualTo(TEST_TILE) + } + + @Test + fun notActiveTileIsAvailableAfterUpdated() = + testScope.runTest { + whenever(tileServiceManager.isActiveTile).thenReturn(false) + customTileStatePersister.persistState( + TileServiceKey(TEST_COMPONENT, TEST_USER.identifier), + TEST_TILE, + ) + val tiles = collectValues(underTest.tiles) + val initJob = launch { underTest.init() } + + underTest.updateTile(TEST_TILE) + runCurrent() + initJob.join() + + assertThat(tiles()).hasSize(1) + assertThat(tiles().last()).isEqualTo(TEST_TILE) + } + + @Test + fun notActiveTileIsAvailableAfterDefaultsUpdated() = + testScope.runTest { + whenever(tileServiceManager.isActiveTile).thenReturn(false) + customTileStatePersister.persistState( + TileServiceKey(TEST_COMPONENT, TEST_USER.identifier), + TEST_TILE, + ) + val tiles = collectValues(underTest.tiles) + val initJob = launch { underTest.init() } + + defaultsRepository.putDefaults(TEST_USER, TEST_COMPONENT, TEST_DEFAULTS) + defaultsRepository.requestNewDefaults(TEST_USER, TEST_COMPONENT) + runCurrent() + initJob.join() + + assertThat(tiles()).hasSize(1) + assertThat(tiles().last()).isEqualTo(TEST_TILE) + } + + @Test(expected = IllegalStateException::class) + fun getTileBeforeInitThrows() = testScope.runTest { underTest.tile } + + @Test + fun initSuspendsForActiveTileNotRestoredAndNotUpdated() = + testScope.runTest { + whenever(tileServiceManager.isActiveTile).thenReturn(true) + val tiles = collectValues(underTest.tiles) + + val initJob = backgroundScope.launch { underTest.init() } + advanceTimeBy(1 * DateUtils.DAY_IN_MILLIS) + + // Is still suspended + assertThat(initJob.isActive).isTrue() + assertThat(tiles()).isEmpty() + } + + @Test + fun initSuspendedForNotActiveTileWithoutUpdates() = + testScope.runTest { + whenever(tileServiceManager.isActiveTile).thenReturn(false) + customTileStatePersister.persistState( + TileServiceKey(TEST_COMPONENT, TEST_USER.identifier), + TEST_TILE, + ) + val tiles = collectValues(underTest.tiles) + + val initJob = backgroundScope.launch { underTest.init() } + advanceTimeBy(1 * DateUtils.DAY_IN_MILLIS) + + // Is still suspended + assertThat(initJob.isActive).isTrue() + assertThat(tiles()).isEmpty() + } + + private companion object { + + val TEST_COMPONENT = ComponentName("test.pkg", "test.cls") + val TEST_TILE_SPEC = TileSpec.create(TEST_COMPONENT) + val TEST_USER = UserHandle.of(1)!! + val TEST_TILE = + Tile().apply { + label = "test_tile_1" + icon = Icon.createWithContentUri("file://test_1") + } + val TEST_DEFAULTS = CustomTileDefaults.Result(TEST_TILE.icon, TEST_TILE.label) + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/external/FakeCustomTileStatePersister.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/external/FakeCustomTileStatePersister.kt new file mode 100644 index 000000000000..29702eb2cdc9 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/external/FakeCustomTileStatePersister.kt @@ -0,0 +1,34 @@ +/* + * 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.external + +import android.service.quicksettings.Tile + +class FakeCustomTileStatePersister : CustomTileStatePersister { + + private val tiles: MutableMap<TileServiceKey, Tile> = mutableMapOf() + + override fun readState(key: TileServiceKey): Tile? = tiles[key] + + override fun persistState(key: TileServiceKey, tile: Tile) { + tiles[key] = tile + } + + override fun removeState(key: TileServiceKey) { + tiles.remove(key) + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/TileSubject.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/TileSubject.kt new file mode 100644 index 000000000000..d2351dc8ae18 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/TileSubject.kt @@ -0,0 +1,71 @@ +/* + * 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.service.quicksettings.Tile +import com.android.systemui.qs.tiles.impl.custom.TileSubject.Companion.assertThat +import com.android.systemui.qs.tiles.impl.custom.TileSubject.Companion.tiles +import com.google.common.truth.FailureMetadata +import com.google.common.truth.Subject +import com.google.common.truth.Subject.Factory +import com.google.common.truth.Truth + +/** + * [Tile]-specific extension for [Truth]. Use [assertThat] or [tiles] to get an instance of this + * subject. + */ +class TileSubject private constructor(failureMetadata: FailureMetadata, subject: Tile?) : + Subject(failureMetadata, subject) { + + private val actual: Tile? = subject + + /** Asserts if the [Tile] fields are the same. */ + fun isEqualTo(other: Tile?) { + if (actual == null) { + check("other").that(other).isNull() + return + } else { + check("other").that(other).isNotNull() + other ?: return + } + + check("icon").that(actual.icon).isEqualTo(other.icon) + check("label").that(actual.label).isEqualTo(other.label) + check("subtitle").that(actual.subtitle).isEqualTo(other.subtitle) + check("contentDescription") + .that(actual.contentDescription) + .isEqualTo(other.contentDescription) + check("stateDescription").that(actual.stateDescription).isEqualTo(other.stateDescription) + check("activityLaunchForClick") + .that(actual.activityLaunchForClick) + .isEqualTo(other.activityLaunchForClick) + check("state").that(actual.state).isEqualTo(other.state) + } + + companion object { + + /** Returns a factory to be used with [Truth.assertAbout]. */ + fun tiles(): Factory<TileSubject, Tile?> { + return Factory { failureMetadata: FailureMetadata, subject: Tile? -> + TileSubject(failureMetadata, subject) + } + } + + /** Shortcut for `Truth.assertAbout(tiles()).that(tile)`. */ + fun assertThat(tile: Tile?): TileSubject = Truth.assertAbout(tiles()).that(tile) + } +} 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 index 13910fd5c564..ccba07273f1e 100644 --- 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 @@ -19,15 +19,20 @@ 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.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull class FakeCustomTileDefaultsRepository : CustomTileDefaultsRepository { private val defaults: MutableMap<DefaultsKey, CustomTileDefaults> = mutableMapOf() - private val defaultsFlow = MutableSharedFlow<DefaultsRequest>() + private val defaultsFlow = + MutableSharedFlow<DefaultsRequest>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val mutableDefaultsRequests: MutableList<DefaultsRequest> = mutableListOf() val defaultsRequests: List<DefaultsRequest> = mutableDefaultsRequests @@ -41,7 +46,7 @@ class FakeCustomTileDefaultsRepository : CustomTileDefaultsRepository { old == new } } - .map { defaults[DefaultsKey(it.user, it.componentName)]!! } + .mapNotNull { defaults[DefaultsKey(it.user, it.componentName)] } override fun requestNewDefaults( user: UserHandle, diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/data/repository/FakeCustomTileRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/data/repository/FakeCustomTileRepository.kt new file mode 100644 index 000000000000..ccf03911495f --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/custom/data/repository/FakeCustomTileRepository.kt @@ -0,0 +1,58 @@ +/* + * 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.os.UserHandle +import android.service.quicksettings.Tile +import com.android.systemui.qs.external.FakeCustomTileStatePersister +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.impl.custom.data.entity.CustomTileDefaults +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.flow.Flow + +class FakeCustomTileRepository( + tileSpec: TileSpec.CustomTileSpec, + customTileStatePersister: FakeCustomTileStatePersister, + testBackgroundContext: CoroutineContext, +) : CustomTileRepository { + + private val realDelegate: CustomTileRepository = + CustomTileRepositoryImpl( + tileSpec, + customTileStatePersister, + testBackgroundContext, + ) + + override suspend fun restoreForTheUserIfNeeded(user: UserHandle, isPersistable: Boolean) = + realDelegate.restoreForTheUserIfNeeded(user, isPersistable) + + override fun getTiles(user: UserHandle): Flow<Tile> = realDelegate.getTiles(user) + + override fun getTile(user: UserHandle): Tile? = realDelegate.getTile(user) + + override suspend fun updateWithTile( + user: UserHandle, + newTile: Tile, + isPersistable: Boolean, + ) = realDelegate.updateWithTile(user, newTile, isPersistable) + + override suspend fun updateWithDefaults( + user: UserHandle, + defaults: CustomTileDefaults, + isPersistable: Boolean, + ) = realDelegate.updateWithDefaults(user, defaults, isPersistable) +} |