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