• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.qs.panels.ui.viewmodel
18 
19 import android.content.Context
20 import androidx.compose.ui.util.fastMap
21 import androidx.recyclerview.widget.DiffUtil
22 import androidx.recyclerview.widget.ListUpdateCallback
23 import com.android.internal.logging.UiEventLogger
24 import com.android.systemui.common.ui.domain.interactor.ConfigurationInteractor
25 import com.android.systemui.dagger.SysUISingleton
26 import com.android.systemui.dagger.qualifiers.Application
27 import com.android.systemui.dagger.qualifiers.Background
28 import com.android.systemui.qs.QSEditEvent
29 import com.android.systemui.qs.panels.domain.interactor.EditTilesListInteractor
30 import com.android.systemui.qs.panels.domain.interactor.GridLayoutTypeInteractor
31 import com.android.systemui.qs.panels.domain.interactor.TilesAvailabilityInteractor
32 import com.android.systemui.qs.panels.shared.model.GridLayoutType
33 import com.android.systemui.qs.panels.ui.compose.GridLayout
34 import com.android.systemui.qs.pipeline.domain.interactor.CurrentTilesInteractor
35 import com.android.systemui.qs.pipeline.domain.interactor.CurrentTilesInteractor.Companion.POSITION_AT_END
36 import com.android.systemui.qs.pipeline.domain.interactor.MinimumTilesInteractor
37 import com.android.systemui.qs.pipeline.shared.TileSpec
38 import com.android.systemui.qs.pipeline.shared.metricSpec
39 import com.android.systemui.shade.ShadeDisplayAware
40 import com.android.systemui.util.kotlin.emitOnStart
41 import javax.inject.Inject
42 import javax.inject.Named
43 import kotlinx.coroutines.CoroutineDispatcher
44 import kotlinx.coroutines.CoroutineScope
45 import kotlinx.coroutines.ExperimentalCoroutinesApi
46 import kotlinx.coroutines.flow.Flow
47 import kotlinx.coroutines.flow.MutableStateFlow
48 import kotlinx.coroutines.flow.SharingStarted
49 import kotlinx.coroutines.flow.StateFlow
50 import kotlinx.coroutines.flow.asStateFlow
51 import kotlinx.coroutines.flow.combine
52 import kotlinx.coroutines.flow.emptyFlow
53 import kotlinx.coroutines.flow.flatMapLatest
54 import kotlinx.coroutines.flow.map
55 import kotlinx.coroutines.flow.stateIn
56 import kotlinx.coroutines.launch
57 
58 @OptIn(ExperimentalCoroutinesApi::class)
59 @SysUISingleton
60 class EditModeViewModel
61 @Inject
62 constructor(
63     private val editTilesListInteractor: EditTilesListInteractor,
64     private val currentTilesInteractor: CurrentTilesInteractor,
65     private val tilesAvailabilityInteractor: TilesAvailabilityInteractor,
66     private val minTilesInteractor: MinimumTilesInteractor,
67     private val uiEventLogger: UiEventLogger,
68     @ShadeDisplayAware private val configurationInteractor: ConfigurationInteractor,
69     @ShadeDisplayAware private val context: Context,
70     @Named("Default") private val defaultGridLayout: GridLayout,
71     @Application private val applicationScope: CoroutineScope,
72     @Background private val bgDispatcher: CoroutineDispatcher,
73     gridLayoutTypeInteractor: GridLayoutTypeInteractor,
74     gridLayoutMap: Map<GridLayoutType, @JvmSuppressWildcards GridLayout>,
75 ) {
76     private val _isEditing = MutableStateFlow(false)
77 
78     /**
79      * Whether we should be editing right now. Use [startEditing] and [stopEditing] to change this.
80      */
81     val isEditing = _isEditing.asStateFlow()
82 
83     val gridLayout: StateFlow<GridLayout> =
84         gridLayoutTypeInteractor.layout
85             .map { gridLayoutMap[it] ?: defaultGridLayout }
86             .stateIn(applicationScope, SharingStarted.WhileSubscribed(), defaultGridLayout)
87 
88     /**
89      * Flow of view models for each tile that should be visible in edit mode (or empty flow when not
90      * editing).
91      *
92      * Guarantees of the data:
93      * * The data for the tiles is fetched once whenever [isEditing] goes from `false` to `true`.
94      *   This prevents icons/labels changing while in edit mode.
95      * * It tracks the current tiles as they are added/removed/moved by the user.
96      * * The tiles that are current will be in the same relative order as the user sees them in
97      *   Quick Settings.
98      * * The tiles that are not current will preserve their relative order even when the current
99      *   tiles change.
100      * * Tiles that are not available will be filtered out. None of them can be current (as they
101      *   cannot be created), and they won't be able to be added.
102      */
103     val tiles: Flow<List<EditTileViewModel>> =
104         isEditing.flatMapLatest {
105             if (it) {
106                 val editTilesData = editTilesListInteractor.getTilesToEdit()
107                 // Query only the non current platform tiles, as any current tile is clearly
108                 // available
109                 val unavailable =
110                     tilesAvailabilityInteractor.getUnavailableTiles(
111                         editTilesData.stockTiles
112                             .map { it.tileSpec }
113                             .minus(currentTilesInteractor.currentTilesSpecs.toSet())
114                     )
115                 currentTilesInteractor.currentTiles
116                     .map { tiles ->
117                         val currentSpecs = tiles.map { it.spec }
118                         val canRemoveTiles = currentSpecs.size > minTilesInteractor.minNumberOfTiles
119                         val allTiles = editTilesData.stockTiles + editTilesData.customTiles
120                         val allTilesMap = allTiles.associateBy { it.tileSpec }
121                         val currentTiles = currentSpecs.mapNotNull { allTilesMap[it] }
122                         val nonCurrentTiles = allTiles.filter { it.tileSpec !in currentSpecs }
123 
124                         (currentTiles + nonCurrentTiles)
125                             .filterNot { it.tileSpec in unavailable }
126                             .map {
127                                 val current = it.tileSpec in currentSpecs
128                                 val availableActions = buildSet {
129                                     if (current) {
130                                         add(AvailableEditActions.MOVE)
131                                         if (canRemoveTiles) {
132                                             add(AvailableEditActions.REMOVE)
133                                         }
134                                     } else {
135                                         add(AvailableEditActions.ADD)
136                                     }
137                                 }
138                                 UnloadedEditTileViewModel(
139                                     it.tileSpec,
140                                     it.icon,
141                                     it.label,
142                                     it.appName,
143                                     current,
144                                     availableActions,
145                                     it.category,
146                                 )
147                             }
148                     }
149                     .combine(configurationInteractor.onAnyConfigurationChange.emitOnStart()) {
150                         tiles,
151                         _ ->
152                         tiles.fastMap { it.load(context) }
153                     }
154             } else {
155                 emptyFlow()
156             }
157         }
158 
159     /** @see isEditing */
160     fun startEditing() {
161         if (!isEditing.value) {
162             uiEventLogger.log(QSEditEvent.QS_EDIT_OPEN)
163         }
164         _isEditing.value = true
165     }
166 
167     /** @see isEditing */
168     fun stopEditing() {
169         if (isEditing.value) {
170             uiEventLogger.log(QSEditEvent.QS_EDIT_CLOSED)
171         }
172         _isEditing.value = false
173     }
174 
175     /**
176      * Immediately adds [tileSpec] to the current tiles at [position]. If the [tileSpec] was already
177      * present, it will be moved to the new position.
178      */
179     fun addTile(tileSpec: TileSpec, position: Int = POSITION_AT_END) {
180         val specs = currentTilesInteractor.currentTilesSpecs.toMutableList()
181         val currentPosition = specs.indexOf(tileSpec)
182         val moved = currentPosition != -1
183 
184         if (currentPosition != -1) {
185             // No operation needed if the element is already in the list at the right position
186             if (currentPosition == position) {
187                 return
188             }
189             // Removing tile if it's present at a different position to insert it at the new index.
190             specs.removeAt(currentPosition)
191         }
192 
193         if (position >= 0 && position < specs.size) {
194             specs.add(position, tileSpec)
195         } else {
196             specs.add(tileSpec)
197         }
198         uiEventLogger.logWithPosition(
199             if (moved) QSEditEvent.QS_EDIT_MOVE else QSEditEvent.QS_EDIT_ADD,
200             /* uid= */ 0,
201             /* packageName= */ tileSpec.metricSpec,
202             if (moved && position == POSITION_AT_END) specs.size - 1 else position,
203         )
204 
205         // Setting the new tiles as one operation to avoid UI jank with tiles disappearing and
206         // reappearing
207         currentTilesInteractor.setTiles(specs)
208     }
209 
210     /** Immediately removes [tileSpec] from the current tiles. */
211     fun removeTile(tileSpec: TileSpec) {
212         uiEventLogger.log(
213             QSEditEvent.QS_EDIT_REMOVE,
214             /* uid= */ 0,
215             /* packageName= */ tileSpec.metricSpec,
216         )
217         currentTilesInteractor.removeTiles(listOf(tileSpec))
218     }
219 
220     fun setTiles(tileSpecs: List<TileSpec>) {
221         val currentTiles = currentTilesInteractor.currentTilesSpecs
222         currentTilesInteractor.setTiles(tileSpecs)
223         applicationScope.launch(bgDispatcher) {
224             calculateDiffsAndEmitUiEvents(currentTiles, tileSpecs)
225         }
226     }
227 
228     private fun calculateDiffsAndEmitUiEvents(
229         currentTiles: List<TileSpec>,
230         newTiles: List<TileSpec>,
231     ) {
232         val listDiff = DiffUtil.calculateDiff(DiffCallback(currentTiles, newTiles))
233         listDiff.dispatchUpdatesTo(
234             object : ListUpdateCallback {
235                 override fun onInserted(position: Int, count: Int) {
236                     newTiles.getOrNull(position)?.let {
237                         uiEventLogger.logWithPosition(
238                             QSEditEvent.QS_EDIT_ADD,
239                             /* uid= */ 0,
240                             /* packageName= */ it.metricSpec,
241                             position,
242                         )
243                     }
244                 }
245 
246                 override fun onRemoved(position: Int, count: Int) {
247                     currentTiles.getOrNull(position)?.let {
248                         uiEventLogger.log(QSEditEvent.QS_EDIT_REMOVE, 0, it.metricSpec)
249                     }
250                 }
251 
252                 override fun onMoved(fromPosition: Int, toPosition: Int) {
253                     currentTiles.getOrNull(fromPosition)?.let {
254                         uiEventLogger.logWithPosition(
255                             QSEditEvent.QS_EDIT_MOVE,
256                             /* uid= */ 0,
257                             /* packageName= */ it.metricSpec,
258                             toPosition,
259                         )
260                     }
261                 }
262 
263                 override fun onChanged(position: Int, count: Int, payload: Any?) {}
264             }
265         )
266     }
267 }
268 
269 private class DiffCallback(
270     private val currentList: List<TileSpec>,
271     private val newList: List<TileSpec>,
272 ) : DiffUtil.Callback() {
getOldListSizenull273     override fun getOldListSize(): Int {
274         return currentList.size
275     }
276 
getNewListSizenull277     override fun getNewListSize(): Int {
278         return newList.size
279     }
280 
areItemsTheSamenull281     override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
282         return currentList[oldItemPosition] == newList[newItemPosition]
283     }
284 
areContentsTheSamenull285     override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
286         return areItemsTheSame(oldItemPosition, newItemPosition)
287     }
288 }
289