summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/QSHost.java5
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/QSTileHost.java2
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/customize/QSCustomizerController.java2
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/pipeline/dagger/QSPipelineModule.kt17
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/pipeline/data/repository/TileSpecRepository.kt191
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/pipeline/prototyping/PrototypeCoreStartable.kt109
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/pipeline/shared/logging/QSPipelineLogger.kt44
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/qs/pipeline/data/repository/TileSpecSettingsRepositoryTest.kt341
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)
+ }
+ }
+}