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