1 /*
<lambda>null2  * Copyright (C) 2023 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 package com.android.systemui.statusbar.notification.icon.ui.viewbinder
17 
18 import android.graphics.Color
19 import android.graphics.Rect
20 import android.util.Log
21 import android.view.View
22 import android.view.ViewGroup
23 import android.widget.FrameLayout
24 import androidx.annotation.ColorInt
25 import androidx.collection.ArrayMap
26 import androidx.lifecycle.lifecycleScope
27 import com.android.app.tracing.traceSection
28 import com.android.internal.R as RInternal
29 import com.android.internal.statusbar.StatusBarIcon
30 import com.android.internal.util.ContrastColorUtil
31 import com.android.systemui.common.ui.ConfigurationState
32 import com.android.systemui.lifecycle.repeatWhenAttached
33 import com.android.systemui.res.R
34 import com.android.systemui.statusbar.StatusBarIconView
35 import com.android.systemui.statusbar.notification.collection.NotifCollection
36 import com.android.systemui.statusbar.notification.icon.IconPack
37 import com.android.systemui.statusbar.notification.icon.ui.viewbinder.NotificationIconContainerViewBinder.IconViewStore
38 import com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconColors
39 import com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerAlwaysOnDisplayViewModel
40 import com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerStatusBarViewModel
41 import com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconsViewData
42 import com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconsViewData.LimitType
43 import com.android.systemui.statusbar.phone.NotificationIconContainer
44 import com.android.systemui.statusbar.ui.SystemBarUtilsState
45 import com.android.systemui.util.kotlin.mapValuesNotNullTo
46 import com.android.systemui.util.ui.isAnimating
47 import com.android.systemui.util.ui.stopAnimating
48 import com.android.systemui.util.ui.value
49 import kotlinx.coroutines.DisposableHandle
50 import kotlinx.coroutines.Job
51 import kotlinx.coroutines.coroutineScope
52 import kotlinx.coroutines.flow.Flow
53 import kotlinx.coroutines.flow.StateFlow
54 import kotlinx.coroutines.flow.combine
55 import kotlinx.coroutines.flow.mapNotNull
56 import kotlinx.coroutines.flow.stateIn
57 import kotlinx.coroutines.launch
58 
59 /** Binds a view-model to a [NotificationIconContainer]. */
60 object NotificationIconContainerViewBinder {
61 
62     suspend fun bind(
63         view: NotificationIconContainer,
64         viewModel: NotificationIconContainerStatusBarViewModel,
65         configuration: ConfigurationState,
66         systemBarUtilsState: SystemBarUtilsState,
67         failureTracker: StatusBarIconViewBindingFailureTracker,
68         viewStore: IconViewStore,
69     ): Unit = coroutineScope {
70         launch {
71             val contrastColorUtil = ContrastColorUtil.getInstance(view.context)
72             val iconColors: StateFlow<NotificationIconColors> =
73                 viewModel.iconColors.mapNotNull { it.iconColors(view.viewBounds) }.stateIn(this)
74             viewModel.icons.bindIcons(
75                 logTag = "statusbar",
76                 view = view,
77                 configuration = configuration,
78                 systemBarUtilsState = systemBarUtilsState,
79                 notifyBindingFailures = { failureTracker.statusBarFailures = it },
80                 viewStore = viewStore,
81             ) { _, sbiv ->
82                 StatusBarIconViewBinder.bindIconColors(
83                     sbiv,
84                     iconColors,
85                     contrastColorUtil,
86                 )
87             }
88         }
89         launch { viewModel.bindIsolatedIcon(view, viewStore) }
90         launch { viewModel.animationsEnabled.bindAnimationsEnabled(view) }
91     }
92 
93     @JvmStatic
94     fun bindWhileAttached(
95         view: NotificationIconContainer,
96         viewModel: NotificationIconContainerAlwaysOnDisplayViewModel,
97         configuration: ConfigurationState,
98         systemBarUtilsState: SystemBarUtilsState,
99         failureTracker: StatusBarIconViewBindingFailureTracker,
100         viewStore: IconViewStore,
101     ): DisposableHandle {
102         return view.repeatWhenAttached {
103             lifecycleScope.launch {
104                 bind(view, viewModel, configuration, systemBarUtilsState, failureTracker, viewStore)
105             }
106         }
107     }
108 
109     suspend fun bind(
110         view: NotificationIconContainer,
111         viewModel: NotificationIconContainerAlwaysOnDisplayViewModel,
112         configuration: ConfigurationState,
113         systemBarUtilsState: SystemBarUtilsState,
114         failureTracker: StatusBarIconViewBindingFailureTracker,
115         viewStore: IconViewStore,
116     ): Unit = coroutineScope {
117         view.setUseIncreasedIconScale(true)
118         launch {
119             // Collect state shared across all icon views, so that we are not duplicating collects
120             // for each individual icon.
121             val color: StateFlow<Int> =
122                 configuration
123                     .getColorAttr(R.attr.wallpaperTextColor, DEFAULT_AOD_ICON_COLOR)
124                     .stateIn(this)
125             val tintAlpha = viewModel.tintAlpha.stateIn(this)
126             val animsEnabled = viewModel.areIconAnimationsEnabled.stateIn(this)
127             viewModel.icons.bindIcons(
128                 logTag = "aod",
129                 view = view,
130                 configuration = configuration,
131                 systemBarUtilsState = systemBarUtilsState,
132                 notifyBindingFailures = { failureTracker.aodFailures = it },
133                 viewStore = viewStore,
134             ) { _, sbiv ->
135                 coroutineScope {
136                     launch { StatusBarIconViewBinder.bindColor(sbiv, color) }
137                     launch { StatusBarIconViewBinder.bindTintAlpha(sbiv, tintAlpha) }
138                     launch { StatusBarIconViewBinder.bindAnimationsEnabled(sbiv, animsEnabled) }
139                 }
140             }
141         }
142         launch { viewModel.areContainerChangesAnimated.bindAnimationsEnabled(view) }
143     }
144 
145     /** Binds to [NotificationIconContainer.setAnimationsEnabled] */
146     private suspend fun Flow<Boolean>.bindAnimationsEnabled(view: NotificationIconContainer) {
147         collectTracingEach("NIC#bindAnimationsEnabled", view::setAnimationsEnabled)
148     }
149 
150     private suspend fun NotificationIconContainerStatusBarViewModel.bindIsolatedIcon(
151         view: NotificationIconContainer,
152         viewStore: IconViewStore,
153     ) {
154         coroutineScope {
155             launch {
156                 isolatedIconLocation.collectTracingEach("NIC#isolatedIconLocation") { location ->
157                     view.setIsolatedIconLocation(location, true)
158                 }
159             }
160             launch {
161                 isolatedIcon.collectTracingEach("NIC#showIconIsolated") { iconInfo ->
162                     val iconView = iconInfo.value?.let { viewStore.iconView(it.notifKey) }
163                     if (iconInfo.isAnimating) {
164                         view.showIconIsolatedAnimated(iconView, iconInfo::stopAnimating)
165                     } else {
166                         view.showIconIsolated(iconView)
167                     }
168                 }
169             }
170         }
171     }
172 
173     /**
174      * Binds [NotificationIconsViewData] to a [NotificationIconContainer]'s children.
175      *
176      * [bindIcon] will be invoked to bind a child [StatusBarIconView] to an icon associated with the
177      * given `iconKey`. The parent [Job] of this coroutine will be cancelled automatically when the
178      * view is to be unbound.
179      */
180     suspend fun Flow<NotificationIconsViewData>.bindIcons(
181         logTag: String,
182         view: NotificationIconContainer,
183         configuration: ConfigurationState,
184         systemBarUtilsState: SystemBarUtilsState,
185         notifyBindingFailures: (Collection<String>) -> Unit,
186         viewStore: IconViewStore,
187         bindIcon: suspend (iconKey: String, view: StatusBarIconView) -> Unit = { _, _ -> },
188     ): Unit = coroutineScope {
189         val iconSizeFlow: Flow<Int> =
190             configuration.getDimensionPixelSize(RInternal.dimen.status_bar_icon_size_sp)
191         val iconHorizontalPaddingFlow: Flow<Int> =
192             configuration.getDimensionPixelSize(R.dimen.status_bar_icon_horizontal_margin)
193         val layoutParams: StateFlow<FrameLayout.LayoutParams> =
194             combine(iconSizeFlow, iconHorizontalPaddingFlow, systemBarUtilsState.statusBarHeight) {
195                     iconSize,
196                     iconHPadding,
197                     statusBarHeight,
198                     ->
199                     FrameLayout.LayoutParams(iconSize + 2 * iconHPadding, statusBarHeight)
200                 }
201                 .stateIn(this)
202         try {
203             bindIcons(logTag, view, layoutParams, notifyBindingFailures, viewStore, bindIcon)
204         } finally {
205             // Detach everything so that child SBIVs don't hold onto a reference to the container.
206             view.detachAllIcons()
207         }
208     }
209 
210     private suspend fun Flow<NotificationIconsViewData>.bindIcons(
211         logTag: String,
212         view: NotificationIconContainer,
213         layoutParams: StateFlow<FrameLayout.LayoutParams>,
214         notifyBindingFailures: (Collection<String>) -> Unit,
215         viewStore: IconViewStore,
216         bindIcon: suspend (iconKey: String, view: StatusBarIconView) -> Unit,
217     ): Unit = coroutineScope {
218         val failedBindings = mutableSetOf<String>()
219         val boundViewsByNotifKey = ArrayMap<String, Pair<StatusBarIconView, Job>>()
220         var prevIcons = NotificationIconsViewData()
221         collectTracingEach({ "NIC($logTag)#bindIcons" }) { iconsData: NotificationIconsViewData ->
222             val iconsDiff = NotificationIconsViewData.computeDifference(iconsData, prevIcons)
223             prevIcons = iconsData
224 
225             // Lookup 1:1 group icon replacements
226             val replacingIcons: ArrayMap<String, StatusBarIcon> =
227                 iconsDiff.groupReplacements.mapValuesNotNullTo(ArrayMap()) { (_, notifKey) ->
228                     boundViewsByNotifKey[notifKey]?.first?.statusBarIcon
229                 }
230             view.withIconReplacements(replacingIcons) {
231                 // Remove and unbind.
232                 for (notifKey in iconsDiff.removed) {
233                     failedBindings.remove(notifKey)
234                     val (child, job) = boundViewsByNotifKey.remove(notifKey) ?: continue
235                     traceSection("removeIcon") {
236                         view.removeView(child)
237                         job.cancel()
238                     }
239                 }
240 
241                 // Add and bind.
242                 val toAdd: Sequence<String> = iconsDiff.added.asSequence() + failedBindings.toList()
243                 for (notifKey in toAdd) {
244                     // Lookup the StatusBarIconView from the store.
245                     val sbiv = viewStore.iconView(notifKey)
246                     if (sbiv == null) {
247                         failedBindings.add(notifKey)
248                         continue
249                     }
250                     failedBindings.remove(notifKey)
251                     traceSection("addIcon") {
252                         (sbiv.parent as? ViewGroup)?.run {
253                             if (this !== view) {
254                                 Log.wtf(
255                                     TAG,
256                                     "[$logTag] SBIV($notifKey) has an unexpected parent",
257                                 )
258                             }
259                             // If the container was re-inflated and re-bound, then SBIVs might still
260                             // be attached to the prior view.
261                             removeView(sbiv)
262                             // The view might still be transiently added if it was just removed and
263                             // added again.
264                             removeTransientView(sbiv)
265                         }
266                         view.addView(sbiv, layoutParams.value)
267                         boundViewsByNotifKey.remove(notifKey)?.second?.cancel()
268                         boundViewsByNotifKey[notifKey] =
269                             Pair(
270                                 sbiv,
271                                 launch {
272                                     launch {
273                                         layoutParams.collectTracingEach(
274                                             tag = { "[$logTag] SBIV#bindLayoutParams" },
275                                         ) {
276                                             if (it != sbiv.layoutParams) {
277                                                 sbiv.layoutParams = it
278                                             }
279                                         }
280                                     }
281                                     bindIcon(notifKey, sbiv)
282                                 },
283                             )
284                     }
285                 }
286 
287                 // Set the maximum number of icons to show in the container. Any icons over this
288                 // amount will render as an "overflow dot".
289                 val maxIconsAmount: Int =
290                     when (iconsData.limitType) {
291                         LimitType.MaximumIndex -> {
292                             iconsData.visibleIcons.asSequence().take(iconsData.iconLimit).count {
293                                 info ->
294                                 info.notifKey in boundViewsByNotifKey
295                             }
296                         }
297                         LimitType.MaximumAmount -> {
298                             iconsData.iconLimit
299                         }
300                     }
301                 view.setMaxIconsAmount(maxIconsAmount)
302 
303                 // Track the binding failures so that they appear in dumpsys.
304                 notifyBindingFailures(failedBindings)
305 
306                 // Re-sort notification icons
307                 view.changeViewPositions {
308                     traceSection("re-sort") {
309                         val expectedChildren: List<StatusBarIconView> =
310                             iconsData.visibleIcons.mapNotNull {
311                                 boundViewsByNotifKey[it.notifKey]?.first
312                             }
313                         val childCount = view.childCount
314                         val toRemove = mutableListOf<View>()
315                         for (i in 0 until childCount) {
316                             val actual = view.getChildAt(i)
317                             val expected = expectedChildren.getOrNull(i)
318                             if (expected == null) {
319                                 Log.wtf(TAG, "[$logTag] Unexpected child $actual")
320                                 toRemove.add(actual)
321                                 continue
322                             }
323                             if (actual === expected) {
324                                 continue
325                             }
326                             view.removeView(expected)
327                             view.addView(expected, i)
328                         }
329                         for (child in toRemove) {
330                             view.removeView(child)
331                         }
332                     }
333                 }
334             }
335         }
336     }
337 
338     /**
339      * Track which groups are being replaced with a different icon instance, but with the same
340      * visual icon. This prevents a weird animation where it looks like an icon disappears and
341      * reappears unchanged.
342      */
343     // TODO(b/305739416): Ideally we wouldn't swap out the StatusBarIconView at all, and instead use
344     //  a single SBIV instance for the group. Then this whole concept can go away.
345     private inline fun <R> NotificationIconContainer.withIconReplacements(
346         replacements: ArrayMap<String, StatusBarIcon>,
347         block: () -> R
348     ): R {
349         setReplacingIcons(replacements)
350         return block().also { setReplacingIcons(null) }
351     }
352 
353     /**
354      * Any invocations of [NotificationIconContainer.addView] /
355      * [NotificationIconContainer.removeView] inside of [block] will not cause a new add / remove
356      * animation.
357      */
358     private inline fun <R> NotificationIconContainer.changeViewPositions(block: () -> R): R {
359         setChangingViewPositions(true)
360         return block().also { setChangingViewPositions(false) }
361     }
362 
363     /** External storage for [StatusBarIconView] instances. */
364     fun interface IconViewStore {
365         fun iconView(key: String): StatusBarIconView?
366     }
367 
368     @ColorInt private const val DEFAULT_AOD_ICON_COLOR = Color.WHITE
369     private const val TAG = "NotifIconContainerViewBinder"
370 }
371 
372 /**
373  * Convenience builder for [IconViewStore] that uses [block] to extract the relevant
374  * [StatusBarIconView] from an [IconPack] stored inside of the [NotifCollection].
375  */
NotifCollectionnull376 fun NotifCollection.iconViewStoreBy(block: (IconPack) -> StatusBarIconView?) =
377     IconViewStore { key ->
378         getEntry(key)?.icons?.let(block)
379     }
380 
381 private val View.viewBounds: Rect
382     get() {
383         val tmpArray = intArrayOf(0, 0)
384         getLocationOnScreen(tmpArray)
385         return Rect(
386             /* left = */ tmpArray[0],
387             /* top = */ tmpArray[1],
388             /* right = */ left + width,
389             /* bottom = */ top + height,
390         )
391     }
392 
collectTracingEachnull393 private suspend inline fun <T> Flow<T>.collectTracingEach(
394     tag: String,
395     crossinline collector: (T) -> Unit,
396 ) = collect { traceSection(tag) { collector(it) } }
397 
collectTracingEachnull398 private suspend inline fun <T> Flow<T>.collectTracingEach(
399     noinline tag: () -> String,
400     crossinline collector: (T) -> Unit,
401 ) {
402     val lazyTag = lazy(mode = LazyThreadSafetyMode.PUBLICATION, tag)
403     collect { traceSection({ lazyTag.value }) { collector(it) } }
404 }
405