diff options
8 files changed, 706 insertions, 5 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSHost.java b/packages/SystemUI/src/com/android/systemui/qs/QSHost.java index 9ece72d2ca7f..6be74a0b5646 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSHost.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSHost.java @@ -40,13 +40,12 @@ public interface QSHost extends PanelInteractor { /** * Returns the default QS tiles for the context. - * @param context the context to obtain the resources from + * @param res the resources to use to determine the default tiles * @return a list of specs of the default tiles */ - static List<String> getDefaultSpecs(Context context) { + static List<String> getDefaultSpecs(Resources res) { final ArrayList<String> tiles = new ArrayList(); - final Resources res = context.getResources(); final String defaultTileList = res.getString(R.string.quick_settings_tiles_default); tiles.addAll(Arrays.asList(defaultTileList.split(","))); diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSTileHost.java b/packages/SystemUI/src/com/android/systemui/qs/QSTileHost.java index 0ead97976ad9..8bbdeeda356c 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSTileHost.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSTileHost.java @@ -600,7 +600,7 @@ public class QSTileHost implements QSHost, Tunable, PluginListener<QSFactory>, P if (tile.isEmpty()) continue; if (tile.equals("default")) { if (!addedDefault) { - List<String> defaultSpecs = QSHost.getDefaultSpecs(context); + List<String> defaultSpecs = QSHost.getDefaultSpecs(context.getResources()); for (String spec : defaultSpecs) { if (!addedSpecs.contains(spec)) { tiles.add(spec); diff --git a/packages/SystemUI/src/com/android/systemui/qs/customize/QSCustomizerController.java b/packages/SystemUI/src/com/android/systemui/qs/customize/QSCustomizerController.java index a319fb8d8756..4002ac3aa120 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/customize/QSCustomizerController.java +++ b/packages/SystemUI/src/com/android/systemui/qs/customize/QSCustomizerController.java @@ -175,7 +175,7 @@ public class QSCustomizerController extends ViewController<QSCustomizer> { private void reset() { - mTileAdapter.resetTileSpecs(QSHost.getDefaultSpecs(getContext())); + mTileAdapter.resetTileSpecs(QSHost.getDefaultSpecs(getContext().getResources())); } public boolean isCustomizing() { 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 9568fb3d04c4..00f0a67dbe22 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 @@ -16,15 +16,32 @@ package com.android.systemui.qs.pipeline.dagger +import com.android.systemui.CoreStartable import com.android.systemui.dagger.SysUISingleton import com.android.systemui.log.LogBufferFactory import com.android.systemui.plugins.log.LogBuffer +import com.android.systemui.qs.pipeline.data.repository.TileSpecRepository +import com.android.systemui.qs.pipeline.data.repository.TileSpecSettingsRepository +import com.android.systemui.qs.pipeline.prototyping.PrototypeCoreStartable import com.android.systemui.qs.pipeline.shared.logging.QSPipelineLogger +import dagger.Binds import dagger.Module import dagger.Provides +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap @Module abstract class QSPipelineModule { + + /** Implementation for [TileSpecRepository] */ + @Binds + abstract fun provideTileSpecRepository(impl: TileSpecSettingsRepository): TileSpecRepository + + @Binds + @IntoMap + @ClassKey(PrototypeCoreStartable::class) + abstract fun providePrototypeCoreStartable(startable: PrototypeCoreStartable): CoreStartable + companion object { /** * Provides a logging buffer for all logs related to the new Quick Settings pipeline to log 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 new file mode 100644 index 000000000000..d254e1b3d0d7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/TileSpecRepository.kt @@ -0,0 +1,191 @@ +/* + * 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.annotation.UserIdInt +import android.content.res.Resources +import android.database.ContentObserver +import android.provider.Settings +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.QSHost +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.pipeline.shared.logging.QSPipelineLogger +import com.android.systemui.util.settings.SecureSettings +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext + +/** Repository that tracks the current tiles. */ +interface TileSpecRepository { + + /** + * Returns a flow of the current list of [TileSpec] for a given [userId]. + * + * Tiles will never be [TileSpec.Invalid] in the list and it will never be empty. + */ + fun tilesSpecs(@UserIdInt userId: Int): Flow<List<TileSpec>> + + /** + * Adds a [tile] for a given [userId] at [position]. Using [POSITION_AT_END] will add the tile + * at the end of the list. + * + * Passing [TileSpec.Invalid] is a noop. + */ + suspend fun addTile(@UserIdInt userId: Int, tile: TileSpec, position: Int = POSITION_AT_END) + + /** + * Removes a [tile] for a given [userId]. + * + * Passing [TileSpec.Invalid] or a non present tile is a noop. + */ + suspend fun removeTile(@UserIdInt userId: Int, tile: TileSpec) + + /** + * Sets the list of current [tiles] for a given [userId]. + * + * [TileSpec.Invalid] will be ignored, and an effectively empty list will not be stored. + */ + suspend fun setTiles(@UserIdInt userId: Int, tiles: List<TileSpec>) + + companion object { + /** Position to indicate the end of the list */ + const val POSITION_AT_END = -1 + } +} + +/** + * Implementation of [TileSpecRepository] that persist the values of tiles in + * [Settings.Secure.QS_TILES]. + * + * All operations against [Settings] will be performed in a background thread. + */ +@SysUISingleton +class TileSpecSettingsRepository +@Inject +constructor( + private val secureSettings: SecureSettings, + @Main private val resources: Resources, + private val logger: QSPipelineLogger, + @Background private val backgroundDispatcher: CoroutineDispatcher, +) : TileSpecRepository { + override fun tilesSpecs(userId: Int): Flow<List<TileSpec>> { + return conflatedCallbackFlow { + val observer = + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + secureSettings.registerContentObserverForUser(SETTING, observer, userId) + + awaitClose { secureSettings.unregisterContentObserver(observer) } + } + .onStart { emit(Unit) } + .map { secureSettings.getStringForUser(SETTING, userId) ?: "" } + .onEach { logger.logTilesChangedInSettings(it, userId) } + .map { parseTileSpecs(it, userId) } + .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) { + tilesList.add(tile) + } else { + tilesList.add(position, tile) + } + storeTiles(userId, tilesList) + } + } + + override suspend fun removeTile(userId: Int, tile: TileSpec) { + if (tile == TileSpec.Invalid) { + return + } + val tilesList = loadTiles(userId).toMutableList() + if (tilesList.remove(tile)) { + 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) + } + } + + private suspend fun loadTiles(@UserIdInt forUser: Int): List<TileSpec> { + return withContext(backgroundDispatcher) { + (secureSettings.getStringForUser(SETTING, forUser) ?: "") + .split(DELIMITER) + .map(TileSpec::create) + .filter { it !is TileSpec.Invalid } + } + } + + private suspend fun storeTiles(@UserIdInt forUser: Int, tiles: List<TileSpec>) { + val toStore = + tiles + .filter { it !is TileSpec.Invalid } + .joinToString(DELIMITER, transform = TileSpec::spec) + withContext(backgroundDispatcher) { + secureSettings.putStringForUser( + SETTING, + toStore, + null, + false, + forUser, + true, + ) + } + } + + private fun parseTileSpecs(tilesFromSettings: String, user: Int): List<TileSpec> { + val fromSettings = + tilesFromSettings.split(DELIMITER).map(TileSpec::create).filter { + it != TileSpec.Invalid + } + return if (fromSettings.isNotEmpty()) { + fromSettings.also { logger.logParsedTiles(it, false, user) } + } else { + QSHost.getDefaultSpecs(resources) + .map(TileSpec::create) + .filter { it != TileSpec.Invalid } + .also { logger.logParsedTiles(it, true, user) } + } + } + + companion object { + private const val SETTING = Settings.Secure.QS_TILES + private const val DELIMITER = "," + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/pipeline/prototyping/PrototypeCoreStartable.kt b/packages/SystemUI/src/com/android/systemui/qs/pipeline/prototyping/PrototypeCoreStartable.kt new file mode 100644 index 000000000000..69d8248a11f5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/pipeline/prototyping/PrototypeCoreStartable.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.prototyping + +import com.android.systemui.CoreStartable +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.qs.pipeline.data.repository.TileSpecRepository +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.statusbar.commandline.Command +import com.android.systemui.statusbar.commandline.CommandRegistry +import com.android.systemui.user.data.repository.UserRepository +import java.io.PrintWriter +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +/** + * Class for observing results while prototyping. + * + * The flows do their own logging, so we just need to make sure that they collect. + * + * This will be torn down together with the last of the new pipeline flags remaining here. + */ +// TODO(b/270385608) +@SysUISingleton +class PrototypeCoreStartable +@Inject +constructor( + private val tileSpecRepository: TileSpecRepository, + private val userRepository: UserRepository, + private val featureFlags: FeatureFlags, + @Application private val scope: CoroutineScope, + private val commandRegistry: CommandRegistry, +) : CoreStartable { + + @OptIn(ExperimentalCoroutinesApi::class) + override fun start() { + if (featureFlags.isEnabled(Flags.QS_PIPELINE_NEW_HOST)) { + scope.launch { + userRepository.selectedUserInfo + .flatMapLatest { user -> tileSpecRepository.tilesSpecs(user.id) } + .collect {} + } + commandRegistry.registerCommand(COMMAND, ::CommandExecutor) + } + } + + private inner class CommandExecutor : Command { + override fun execute(pw: PrintWriter, args: List<String>) { + if (args.size < 2) { + pw.println("Error: needs at least two arguments") + return + } + val spec = TileSpec.create(args[1]) + if (spec == TileSpec.Invalid) { + pw.println("Error: Invalid tile spec ${args[1]}") + } + if (args[0] == "add") { + performAdd(args, spec) + pw.println("Requested tile added") + } else if (args[0] == "remove") { + performRemove(args, spec) + pw.println("Requested tile removed") + } else { + pw.println("Error: unknown command") + } + } + + private fun performAdd(args: List<String>, spec: TileSpec) { + val position = args.getOrNull(2)?.toInt() ?: TileSpecRepository.POSITION_AT_END + val user = args.getOrNull(3)?.toInt() ?: userRepository.getSelectedUserInfo().id + scope.launch { tileSpecRepository.addTile(user, spec, position) } + } + + private fun performRemove(args: List<String>, spec: TileSpec) { + val user = args.getOrNull(2)?.toInt() ?: userRepository.getSelectedUserInfo().id + scope.launch { tileSpecRepository.removeTile(user, spec) } + } + + override fun help(pw: PrintWriter) { + pw.println("Usage: adb shell cmd statusbar $COMMAND:") + pw.println(" add <spec> [position] [user]") + pw.println(" remove <spec> [user]") + } + } + + companion object { + private const val COMMAND = "qs-pipeline" + } +} 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 feb7c450de08..200f7431e906 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 @@ -16,10 +16,18 @@ package com.android.systemui.qs.pipeline.shared.logging +import android.annotation.UserIdInt import com.android.systemui.plugins.log.LogBuffer +import com.android.systemui.plugins.log.LogLevel import com.android.systemui.qs.pipeline.dagger.QSTileListLog +import com.android.systemui.qs.pipeline.shared.TileSpec import javax.inject.Inject +/** + * Logger for the new pipeline. + * + * This may log to different buffers depending of the function of the log. + */ class QSPipelineLogger @Inject constructor( @@ -29,4 +37,40 @@ constructor( companion object { const val TILE_LIST_TAG = "QSTileListLog" } + + /** + * Log the tiles that are parsed in the repo. This is effectively what is surfaces in the flow. + * + * [usesDefault] indicates if the default tiles were used (due to the setting being empty or + * invalid). + */ + fun logParsedTiles(tiles: List<TileSpec>, usesDefault: Boolean, user: Int) { + tileListLogBuffer.log( + TILE_LIST_TAG, + LogLevel.DEBUG, + { + str1 = tiles.toString() + bool1 = usesDefault + int1 = user + }, + { "Parsed tiles (default=$bool1, user=$int1): $str1" } + ) + } + + /** + * Logs when the tiles change in Settings. + * + * This could be caused by SystemUI, or restore. + */ + fun logTilesChangedInSettings(newTiles: String, @UserIdInt user: Int) { + tileListLogBuffer.log( + TILE_LIST_TAG, + LogLevel.VERBOSE, + { + str1 = newTiles + int1 = user + }, + { "Tiles changed in settings for user $int1: $str1" } + ) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/TileSpecSettingsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/TileSpecSettingsRepositoryTest.kt new file mode 100644 index 000000000000..c03849b35f54 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/TileSpecSettingsRepositoryTest.kt @@ -0,0 +1,341 @@ +/* + * 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.provider.Settings +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.qs.QSHost +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.pipeline.shared.logging.QSPipelineLogger +import com.android.systemui.util.settings.FakeSettings +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +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(AndroidTestingRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class TileSpecSettingsRepositoryTest : SysuiTestCase() { + + private lateinit var secureSettings: FakeSettings + + @Mock private lateinit var logger: QSPipelineLogger + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var underTest: TileSpecSettingsRepository + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + secureSettings = FakeSettings() + + with(context.orCreateTestableResources) { + addOverride(R.string.quick_settings_tiles_default, DEFAULT_TILES) + } + + underTest = + TileSpecSettingsRepository( + secureSettings, + context.resources, + logger, + testDispatcher, + ) + } + + @Test + fun emptySetting_usesDefaultValue() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + assertThat(tiles).isEqualTo(getDefaultTileSpecs()) + } + + @Test + fun changeInSettings_changesValue() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + storeTilesForUser("a", 0) + assertThat(tiles).isEqualTo(listOf(TileSpec.create("a"))) + + storeTilesForUser("a,custom(b/c)", 0) + assertThat(tiles) + .isEqualTo(listOf(TileSpec.create("a"), TileSpec.create("custom(b/c)"))) + } + + @Test + fun tilesForCorrectUsers() = + testScope.runTest { + val tilesFromUser0 by collectLastValue(underTest.tilesSpecs(0)) + val tilesFromUser1 by collectLastValue(underTest.tilesSpecs(1)) + + val user0Tiles = "a" + val user1Tiles = "custom(b/c)" + storeTilesForUser(user0Tiles, 0) + storeTilesForUser(user1Tiles, 1) + + assertThat(tilesFromUser0).isEqualTo(user0Tiles.toTileSpecs()) + assertThat(tilesFromUser1).isEqualTo(user1Tiles.toTileSpecs()) + } + + @Test + fun invalidTilesAreNotPresent() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + val specs = "d,custom(bad)" + storeTilesForUser(specs, 0) + + assertThat(tiles).isEqualTo(specs.toTileSpecs().filter { it != TileSpec.Invalid }) + } + + @Test + fun noValidTiles_defaultSet() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + storeTilesForUser("custom(bad),custom()", 0) + + assertThat(tiles).isEqualTo(getDefaultTileSpecs()) + } + + @Test + fun addTileAtEnd() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + storeTilesForUser("a", 0) + + underTest.addTile(userId = 0, TileSpec.create("b")) + + val expected = "a,b" + assertThat(loadTilesForUser(0)).isEqualTo(expected) + assertThat(tiles).isEqualTo(expected.toTileSpecs()) + } + + @Test + fun addTileAtPosition() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + storeTilesForUser("a,custom(b/c)", 0) + + underTest.addTile(userId = 0, TileSpec.create("d"), position = 1) + + val expected = "a,d,custom(b/c)" + assertThat(loadTilesForUser(0)).isEqualTo(expected) + assertThat(tiles).isEqualTo(expected.toTileSpecs()) + } + + @Test + fun addInvalidTile_noop() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + val specs = "a,custom(b/c)" + storeTilesForUser(specs, 0) + + underTest.addTile(userId = 0, TileSpec.Invalid) + + assertThat(loadTilesForUser(0)).isEqualTo(specs) + assertThat(tiles).isEqualTo(specs.toTileSpecs()) + } + + @Test + fun addTileForOtherUser_addedInThatUser() = + testScope.runTest { + val tilesUser0 by collectLastValue(underTest.tilesSpecs(0)) + val tilesUser1 by collectLastValue(underTest.tilesSpecs(1)) + + storeTilesForUser("a", 0) + storeTilesForUser("b", 1) + + underTest.addTile(userId = 1, TileSpec.create("c")) + + assertThat(loadTilesForUser(0)).isEqualTo("a") + assertThat(tilesUser0).isEqualTo("a".toTileSpecs()) + assertThat(loadTilesForUser(1)).isEqualTo("b,c") + assertThat(tilesUser1).isEqualTo("b,c".toTileSpecs()) + } + + @Test + fun removeTile() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + storeTilesForUser("a,b", 0) + + underTest.removeTile(userId = 0, TileSpec.create("a")) + + assertThat(loadTilesForUser(0)).isEqualTo("b") + assertThat(tiles).isEqualTo("b".toTileSpecs()) + } + + @Test + fun removeTileNotThere_noop() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + val specs = "a,b" + storeTilesForUser(specs, 0) + + underTest.removeTile(userId = 0, TileSpec.create("c")) + + assertThat(loadTilesForUser(0)).isEqualTo(specs) + assertThat(tiles).isEqualTo(specs.toTileSpecs()) + } + + @Test + fun removeInvalidTile_noop() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + val specs = "a,b" + storeTilesForUser(specs, 0) + + underTest.removeTile(userId = 0, TileSpec.Invalid) + + assertThat(loadTilesForUser(0)).isEqualTo(specs) + assertThat(tiles).isEqualTo(specs.toTileSpecs()) + } + + @Test + fun removeTileFromSecondaryUser_removedOnlyInCorrectUser() = + testScope.runTest { + val user0Tiles by collectLastValue(underTest.tilesSpecs(0)) + val user1Tiles by collectLastValue(underTest.tilesSpecs(1)) + + val specs = "a,b" + storeTilesForUser(specs, 0) + storeTilesForUser(specs, 1) + + underTest.removeTile(userId = 1, TileSpec.create("a")) + + assertThat(loadTilesForUser(0)).isEqualTo(specs) + assertThat(user0Tiles).isEqualTo(specs.toTileSpecs()) + assertThat(loadTilesForUser(1)).isEqualTo("b") + assertThat(user1Tiles).isEqualTo("b".toTileSpecs()) + } + + @Test + fun changeTiles() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + val specs = "a,custom(b/c)" + + underTest.setTiles(userId = 0, specs.toTileSpecs()) + + assertThat(loadTilesForUser(0)).isEqualTo(specs) + assertThat(tiles).isEqualTo(specs.toTileSpecs()) + } + + @Test + fun changeTiles_ignoresInvalid() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + val specs = "a,custom(b/c)" + + underTest.setTiles(userId = 0, listOf(TileSpec.Invalid) + specs.toTileSpecs()) + + assertThat(loadTilesForUser(0)).isEqualTo(specs) + assertThat(tiles).isEqualTo(specs.toTileSpecs()) + } + + @Test + fun changeTiles_empty_noChanges() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + underTest.setTiles(userId = 0, emptyList()) + + assertThat(loadTilesForUser(0)).isNull() + assertThat(tiles).isEqualTo(getDefaultTileSpecs()) + } + + @Test + fun changeTiles_forCorrectUser() = + testScope.runTest { + val user0Tiles by collectLastValue(underTest.tilesSpecs(0)) + val user1Tiles by collectLastValue(underTest.tilesSpecs(1)) + + val specs = "a" + storeTilesForUser(specs, 0) + storeTilesForUser(specs, 1) + + underTest.setTiles(userId = 1, "b".toTileSpecs()) + + assertThat(loadTilesForUser(0)).isEqualTo("a") + assertThat(user0Tiles).isEqualTo(specs.toTileSpecs()) + + assertThat(loadTilesForUser(1)).isEqualTo("b") + assertThat(user1Tiles).isEqualTo("b".toTileSpecs()) + } + + @Test + fun multipleConcurrentRemovals_bothRemoved() = + testScope.runTest { + val tiles by collectLastValue(underTest.tilesSpecs(0)) + + val specs = "a,b,c" + storeTilesForUser(specs, 0) + + coroutineScope { + underTest.removeTile(userId = 0, TileSpec.create("c")) + underTest.removeTile(userId = 0, TileSpec.create("a")) + } + + assertThat(loadTilesForUser(0)).isEqualTo("b") + assertThat(tiles).isEqualTo("b".toTileSpecs()) + } + + private fun getDefaultTileSpecs(): List<TileSpec> { + return QSHost.getDefaultSpecs(context.resources).map(TileSpec::create) + } + + private fun storeTilesForUser(specs: String, forUser: Int) { + secureSettings.putStringForUser(SETTING, specs, forUser) + } + + private fun loadTilesForUser(forUser: Int): String? { + return secureSettings.getStringForUser(SETTING, forUser) + } + + companion object { + private const val DEFAULT_TILES = "a,b,c" + private const val SETTING = Settings.Secure.QS_TILES + + private fun String.toTileSpecs(): List<TileSpec> { + return split(",").map(TileSpec::create) + } + } +} |