• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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