1 /* <lambda>null2 * Copyright (C) 2025 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.tiles.base.ui.viewmodel 18 19 import android.content.Context 20 import android.os.UserHandle 21 import android.util.Log 22 import com.android.internal.logging.InstanceId 23 import com.android.systemui.Dumpable 24 import com.android.systemui.animation.Expandable 25 import com.android.systemui.common.shared.model.Icon 26 import com.android.systemui.dagger.qualifiers.Application 27 import com.android.systemui.dagger.qualifiers.UiBackground 28 import com.android.systemui.plugins.qs.QSTile 29 import com.android.systemui.plugins.qs.TileDetailsViewModel 30 import com.android.systemui.qs.QSHost 31 import com.android.systemui.qs.tileimpl.QSTileImpl.DrawableIcon 32 import com.android.systemui.qs.tileimpl.QSTileImpl.DrawableIconWithRes 33 import com.android.systemui.qs.tileimpl.QSTileImpl.ResourceIcon 34 import com.android.systemui.qs.tiles.base.shared.model.QSTileConfig 35 import com.android.systemui.qs.tiles.base.shared.model.QSTileState 36 import com.android.systemui.qs.tiles.base.shared.model.QSTileUIConfig 37 import com.android.systemui.qs.tiles.base.shared.model.QSTileUserAction 38 import dagger.assisted.Assisted 39 import dagger.assisted.AssistedFactory 40 import dagger.assisted.AssistedInject 41 import java.io.PrintWriter 42 import java.util.concurrent.CopyOnWriteArraySet 43 import kotlinx.coroutines.CoroutineDispatcher 44 import kotlinx.coroutines.CoroutineScope 45 import kotlinx.coroutines.Job 46 import kotlinx.coroutines.flow.collectIndexed 47 import kotlinx.coroutines.flow.filterNotNull 48 import kotlinx.coroutines.flow.flowOn 49 import kotlinx.coroutines.flow.launchIn 50 import kotlinx.coroutines.flow.map 51 import kotlinx.coroutines.flow.onEach 52 import kotlinx.coroutines.flow.takeWhile 53 import kotlinx.coroutines.launch 54 55 // TODO(b/http://b/299909989): Use QSTileViewModel directly after the rollout 56 class QSTileViewModelAdapter 57 @AssistedInject 58 constructor( 59 @Application private val applicationScope: CoroutineScope, 60 private val qsHost: QSHost, 61 @Assisted private val qsTileViewModel: QSTileViewModel, 62 @UiBackground private val uiBgDispatcher: CoroutineDispatcher, 63 ) : QSTile, Dumpable { 64 65 private val context 66 get() = qsHost.context 67 68 private val callbacks = CopyOnWriteArraySet<QSTile.Callback>() 69 private val listeningClients = CopyOnWriteArraySet<Any>() 70 71 // Cancels the jobs when the adapter is no longer alive 72 private var tileAdapterJob: Job? = null 73 // Cancels the jobs when clients stop listening 74 private var stateJob: Job? = null 75 76 init { 77 tileAdapterJob = 78 applicationScope.launch { 79 launch { 80 qsTileViewModel.isAvailable.collectIndexed { index, isAvailable -> 81 if (!isAvailable && qsTileViewModel.config.autoRemoveOnUnavailable) { 82 qsHost.removeTile(tileSpec) 83 } 84 // qsTileViewModel.isAvailable flow often starts with isAvailable == true. 85 // That's 86 // why we only allow isAvailable == true once and throw an exception 87 // afterwards. 88 if (index > 0 && isAvailable) { 89 // See com.android.systemui.qs.pipeline.domain.model.AutoAddable for 90 // additional 91 // guidance on how to auto add your tile 92 throw UnsupportedOperationException( 93 "Turning on tile is not supported now. Tile spec: $tileSpec" 94 ) 95 } 96 } 97 } 98 // Warm up tile with some initial state. Because `state` is a StateFlow with initial 99 // state `null`, we collect until it's not null. 100 launch { qsTileViewModel.state.takeWhile { it == null }.collect {} } 101 } 102 103 // QSTileHost doesn't call this when userId is initialized 104 userSwitch(qsHost.userId) 105 106 if (DEBUG) { 107 Log.d(TAG, "Using new tiles for: $tileSpec") 108 } 109 } 110 111 override fun isAvailable(): Boolean = qsTileViewModel.isAvailable.value 112 113 override fun setTileSpec(tileSpec: String?) { 114 throw UnsupportedOperationException("Tile spec is immutable in new tiles") 115 } 116 117 override fun refreshState() { 118 qsTileViewModel.forceUpdate() 119 } 120 121 override fun addCallback(callback: QSTile.Callback?) { 122 callback ?: return 123 callbacks.add(callback) 124 state.copyTo(cachedState) 125 state.let(callback::onStateChanged) 126 } 127 128 override fun removeCallback(callback: QSTile.Callback?) { 129 callback ?: return 130 callbacks.remove(callback) 131 } 132 133 override fun removeCallbacks() { 134 callbacks.clear() 135 } 136 137 override fun click(expandable: Expandable?) { 138 if (isActionSupported(QSTileState.UserAction.CLICK)) { 139 qsTileViewModel.onActionPerformed(QSTileUserAction.Click(expandable)) 140 } 141 } 142 143 override fun secondaryClick(expandable: Expandable?) { 144 if (isActionSupported(QSTileState.UserAction.TOGGLE_CLICK)) { 145 qsTileViewModel.onActionPerformed(QSTileUserAction.ToggleClick(expandable)) 146 } 147 } 148 149 override fun longClick(expandable: Expandable?) { 150 if (isActionSupported(QSTileState.UserAction.LONG_CLICK)) { 151 qsTileViewModel.onActionPerformed(QSTileUserAction.LongClick(expandable)) 152 } 153 } 154 155 private fun isActionSupported(action: QSTileState.UserAction): Boolean = 156 qsTileViewModel.currentState?.supportedActions?.contains(action) == true 157 158 override fun userSwitch(currentUser: Int) { 159 qsTileViewModel.onUserChanged(UserHandle.of(currentUser)) 160 } 161 162 override fun getCurrentTileUser(): Int { 163 return qsTileViewModel.currentTileUser 164 } 165 166 override fun getDetailsViewModel(): TileDetailsViewModel? { 167 return qsTileViewModel.tileDetailsViewModel 168 } 169 170 @Deprecated( 171 "Not needed as {@link com.android.internal.logging.UiEvent} will use #getMetricsSpec", 172 replaceWith = ReplaceWith("getMetricsSpec"), 173 ) 174 override fun getMetricsCategory(): Int = 0 175 176 override fun isTileReady(): Boolean = qsTileViewModel.currentState != null 177 178 private var cachedState = QSTile.AdapterState() 179 180 override fun setListening(client: Any?, listening: Boolean) { 181 client ?: return 182 if (listening) { 183 applicationScope.launch(uiBgDispatcher) { 184 val shouldStartMappingJob = 185 listeningClients.add(client) // new client 186 && listeningClients.size == 1 // first client 187 188 if (shouldStartMappingJob) { 189 stateJob = 190 qsTileViewModel.state 191 .filterNotNull() 192 .map { mapState(context, it, qsTileViewModel.config) } 193 .onEach { legacyState -> 194 val changed = legacyState.copyTo(cachedState) 195 if (changed) { 196 callbacks.forEach { it.onStateChanged(legacyState) } 197 } 198 } 199 .flowOn(uiBgDispatcher) 200 .launchIn(applicationScope) 201 } 202 } 203 } else { 204 listeningClients.remove(client) 205 if (listeningClients.isEmpty()) { 206 stateJob?.cancel() 207 } 208 } 209 } 210 211 override fun isListening(): Boolean = listeningClients.isNotEmpty() 212 213 override fun setDetailListening(show: Boolean) { 214 // do nothing like QSTileImpl 215 } 216 217 override fun destroy() { 218 stateJob?.cancel() 219 tileAdapterJob?.cancel() 220 qsTileViewModel.destroy() 221 } 222 223 override fun isDestroyed(): Boolean { 224 return !(tileAdapterJob?.isActive ?: false) 225 } 226 227 override fun getState(): QSTile.AdapterState = 228 qsTileViewModel.currentState?.let { mapState(context, it, qsTileViewModel.config) } 229 ?: QSTile.AdapterState() 230 231 override fun getInstanceId(): InstanceId = qsTileViewModel.config.instanceId 232 233 override fun getTileLabel(): CharSequence = 234 with(qsTileViewModel.config.uiConfig) { 235 when (this) { 236 is QSTileUIConfig.Empty -> qsTileViewModel.currentState?.label ?: "" 237 is QSTileUIConfig.Resource -> context.getString(labelRes) 238 } 239 } 240 241 override fun getTileSpec(): String = qsTileViewModel.config.tileSpec.spec 242 243 override fun dump(pw: PrintWriter, args: Array<out String>) = 244 (qsTileViewModel as? Dumpable)?.dump(pw, args) 245 ?: pw.println("${getTileSpec()}: QSTileViewModel isn't dumpable") 246 247 private companion object { 248 249 const val DEBUG = false 250 const val TAG = "QSTileVMAdapter" 251 252 fun mapState( 253 context: Context, 254 viewModelState: QSTileState, 255 config: QSTileConfig, 256 ): QSTile.AdapterState = 257 // we have to use QSTile.BooleanState to support different side icons 258 // which are bound to instanceof QSTile.BooleanState in QSTileView. 259 QSTile.AdapterState().apply { 260 spec = config.tileSpec.spec 261 label = viewModelState.label 262 // This value is synthetic and doesn't have any meaning. It's only needed to satisfy 263 // CTS tests. 264 value = viewModelState.activationState == QSTileState.ActivationState.ACTIVE 265 266 secondaryLabel = viewModelState.secondaryLabel 267 handlesLongClick = 268 viewModelState.supportedActions.contains(QSTileState.UserAction.LONG_CLICK) 269 handlesSecondaryClick = 270 viewModelState.supportedActions.contains(QSTileState.UserAction.TOGGLE_CLICK) 271 272 icon = 273 when (val stateIcon = viewModelState.icon) { 274 is Icon.Loaded -> 275 if (stateIcon.res == null) DrawableIcon(stateIcon.drawable) 276 else DrawableIconWithRes(stateIcon.drawable, stateIcon.res) 277 is Icon.Resource -> ResourceIcon.get(stateIcon.res) 278 null -> null 279 } 280 281 state = viewModelState.activationState.legacyState 282 283 contentDescription = viewModelState.contentDescription 284 stateDescription = viewModelState.stateDescription 285 286 disabledByPolicy = viewModelState.enabledState == QSTileState.EnabledState.DISABLED 287 expandedAccessibilityClassName = viewModelState.expandedAccessibilityClassName 288 289 // Use LoopedAnimatable2DrawableWrapper to achieve animated tile icon 290 isTransient = false 291 292 when (viewModelState.sideViewIcon) { 293 is QSTileState.SideViewIcon.Custom -> { 294 sideViewCustomDrawable = 295 when (viewModelState.sideViewIcon.icon) { 296 is Icon.Loaded -> viewModelState.sideViewIcon.icon.drawable 297 is Icon.Resource -> 298 context.getDrawable(viewModelState.sideViewIcon.icon.res) 299 } 300 } 301 is QSTileState.SideViewIcon.Chevron -> { 302 forceExpandIcon = true 303 } 304 is QSTileState.SideViewIcon.None -> { 305 forceExpandIcon = false 306 } 307 } 308 } 309 } 310 311 @AssistedFactory 312 interface Factory { 313 314 fun create(qsTileViewModel: QSTileViewModel): QSTileViewModelAdapter 315 } 316 } 317