Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 62937a1f authored by Anton Potapov's avatar Anton Potapov
Browse files

Add tile interactor and repository to handle Tile update and persisting

Flag: LEGACY QS_PIPELINE_NEW_TILES DISABLED
Test: atest CustomTileRepositoryTest
Test: atest CustomTileInteractorTest
Bug: 301055700
Change-Id: I70bf12d6957c3b01edc5cea629e9616feb3b8ab0
parent b9977e5f
Loading
Loading
Loading
Loading
+31 −20
Original line number Diff line number Diff line
@@ -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()
    }
}
+5 −0
Original line number Diff line number Diff line
@@ -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
}
+50 −0
Original line number Diff line number Diff line
/*
 * 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
}
+196 −0
Original line number Diff line number Diff line
/*
 * 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)
}
+4 −0
Original line number Diff line number Diff line
@@ -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
}
Loading