• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2022 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.statusbar.notification.stack
18 
19 import android.content.res.Resources
20 import android.util.Log
21 import android.view.View.GONE
22 import androidx.annotation.VisibleForTesting
23 import com.android.systemui.dagger.SysUISingleton
24 import com.android.systemui.dagger.qualifiers.Application
25 import com.android.systemui.media.controls.domain.pipeline.MediaDataManager
26 import com.android.systemui.res.R
27 import com.android.systemui.shade.ShadeDisplayAware
28 import com.android.systemui.statusbar.LockscreenShadeTransitionController
29 import com.android.systemui.statusbar.StatusBarState.KEYGUARD
30 import com.android.systemui.statusbar.SysuiStatusBarStateController
31 import com.android.systemui.statusbar.notification.domain.interactor.SeenNotificationsInteractor
32 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
33 import com.android.systemui.statusbar.notification.row.ExpandableView
34 import com.android.systemui.statusbar.notification.shared.NotificationBundleUi
35 import com.android.systemui.statusbar.notification.shared.NotificationMinimalism
36 import com.android.systemui.statusbar.policy.SplitShadeStateController
37 import com.android.systemui.util.Compile
38 import com.android.systemui.util.children
39 import java.io.PrintWriter
40 import javax.inject.Inject
41 import kotlin.math.max
42 import kotlin.math.min
43 import kotlin.properties.Delegates.notNull
44 import kotlinx.coroutines.CoroutineScope
45 import kotlinx.coroutines.flow.collectLatest
46 import kotlinx.coroutines.launch
47 
48 private const val TAG = "NotifStackSizeCalc"
49 private val DEBUG = Compile.IS_DEBUG && Log.isLoggable(TAG, Log.DEBUG)
50 private val SPEW = Compile.IS_DEBUG && Log.isLoggable(TAG, Log.VERBOSE)
51 
52 /**
53  * Calculates number of notifications to display and the height of the notification stack.
54  * "Notifications" refers to any ExpandableView that we show on lockscreen, which can include the
55  * media player.
56  */
57 @SysUISingleton
58 class NotificationStackSizeCalculator
59 @Inject
60 constructor(
61     private val statusBarStateController: SysuiStatusBarStateController,
62     private val lockscreenShadeTransitionController: LockscreenShadeTransitionController,
63     private val mediaDataManager: MediaDataManager,
64     @ShadeDisplayAware private val resources: Resources,
65     private val splitShadeStateController: SplitShadeStateController,
66     private val seenNotificationsInteractor: SeenNotificationsInteractor,
67     @Application private val scope: CoroutineScope,
68 ) {
69 
70     /**
71      * Maximum # notifications to show on Keyguard; extras will be collapsed in an overflow shelf.
72      * If there are exactly 1 + mMaxKeyguardNotifications, and they fit in the available space
73      * (considering the overflow shelf is not displayed in this case), then all notifications are
74      * shown.
75      */
76     private var maxKeyguardNotifications by notNull<Int>()
77 
78     /**
79      * Whether [maxKeyguardNotifications] will have 1 added to it when media is shown in the stack.
80      */
81     private var maxNotificationsExcludesMedia = false
82 
83     /** Whether we allow keyguard to show less important notifications above the shelf. */
84     private val limitLockScreenToOneImportant
85         get() = NotificationMinimalism.isEnabled && minimalismSettingEnabled
86 
87     /** Minimum space between two notifications, see [calculateGapAndDividerHeight]. */
88     private var dividerHeight by notNull<Float>()
89 
90     /**
91      * True when there is not enough vertical space to show at least one notification with heads up
92      * layout. When true, notifications always show collapsed layout.
93      */
94     private var saveSpaceOnLockscreen = false
95 
96     /** True when the lock screen notification minimalism feature setting is enabled */
97     private var minimalismSettingEnabled = false
98 
99     init {
100         updateResources()
101         if (NotificationMinimalism.isEnabled) {
102             scope.launch { trackLockScreenNotificationMinimalismSettingChanges() }
103         }
104     }
105 
106     private fun allowedByPolicy(stackHeight: StackHeight): Boolean =
107         if (stackHeight.shouldForceIntoShelf) {
108             log { "\tallowedByPolicy = false" }
109             false
110         } else {
111             true
112         }
113 
114     /**
115      * Returns whether notifications and (shelf if visible) can fit in total space available.
116      * [shelfSpace] is extra vertical space allowed for the shelf to overlap the lock icon.
117      */
118     private fun canStackFitInSpace(
119         stackHeight: StackHeight,
120         notifSpace: Float,
121         shelfSpace: Float,
122     ): FitResult {
123         val (notifHeight, notifHeightSaveSpace, shelfHeightWithSpaceBefore) = stackHeight
124 
125         if (shelfHeightWithSpaceBefore == 0f) {
126             if (notifHeight <= notifSpace) {
127                 log {
128                     "\tcanStackFitInSpace[FIT] = notifHeight[$notifHeight]" +
129                         " <= notifSpace[$notifSpace]"
130                 }
131                 return FitResult.FIT
132             }
133             if (notifHeightSaveSpace <= notifSpace) {
134                 log {
135                     "\tcanStackFitInSpace[FIT_IF_SAVE_SPACE]" +
136                         " = notifHeightSaveSpace[$notifHeightSaveSpace]" +
137                         " <= notifSpace[$notifSpace]"
138                 }
139                 return FitResult.FIT_IF_SAVE_SPACE
140             }
141             log {
142                 "\tcanStackFitInSpace[NO_FIT]" +
143                     " = notifHeightSaveSpace[$notifHeightSaveSpace] > notifSpace[$notifSpace]"
144             }
145             return FitResult.NO_FIT
146         } else {
147             if ((notifHeight + shelfHeightWithSpaceBefore) <= (notifSpace + shelfSpace)) {
148                 log {
149                     "\tcanStackFitInSpace[FIT] = (notifHeight[$notifHeight]" +
150                         " + shelfHeightWithSpaceBefore[$shelfHeightWithSpaceBefore])" +
151                         " <= (notifSpace[$notifSpace] " +
152                         " + spaceForShelf[$shelfSpace])"
153                 }
154                 return FitResult.FIT
155             } else if (
156                 (notifHeightSaveSpace + shelfHeightWithSpaceBefore) <= (notifSpace + shelfSpace)
157             ) {
158                 log {
159                     "\tcanStackFitInSpace[FIT_IF_SAVE_SPACE]" +
160                         " = (notifHeightSaveSpace[$notifHeightSaveSpace]" +
161                         " + shelfHeightWithSpaceBefore[$shelfHeightWithSpaceBefore])" +
162                         " <= (notifSpace[$notifSpace] + shelfSpace[$shelfSpace])"
163                 }
164                 return FitResult.FIT_IF_SAVE_SPACE
165             } else {
166                 log {
167                     "\tcanStackFitInSpace[NO_FIT]" +
168                         " = (notifHeightSaveSpace[$notifHeightSaveSpace]" +
169                         " + shelfHeightWithSpaceBefore[$shelfHeightWithSpaceBefore])" +
170                         " > (notifSpace[$notifSpace] + shelfSpace[$shelfSpace])"
171                 }
172                 return FitResult.NO_FIT
173             }
174         }
175     }
176 
177     /**
178      * Given the [notifSpace] and [shelfSpace] constraints, calculate how many notifications to
179      * show. This number is only valid in keyguard.
180      *
181      * @param totalAvailableSpace space for notifications. This includes the space for the shelf.
182      */
183     fun computeMaxKeyguardNotifications(
184         stack: NotificationStackScrollLayout,
185         notifSpace: Float,
186         shelfSpace: Float,
187         shelfHeight: Float,
188     ): Int {
189         log { "\n " }
190         log {
191             "computeMaxKeyguardNotifications ---" +
192                 "\n\tnotifSpace $notifSpace" +
193                 "\n\tspaceForShelf $shelfSpace" +
194                 "\n\tshelfIntrinsicHeight $shelfHeight"
195         }
196         if (notifSpace + shelfSpace <= 0f) {
197             log { "--- No space to show anything. maxNotifs=0" }
198             return 0
199         }
200         log { "\n" }
201 
202         val stackHeightSequence = computeHeightPerNotificationLimit(stack, shelfHeight)
203 
204         // TODO: Avoid making this split shade assumption by simply checking the stack for media
205         val isMediaShowing = mediaDataManager.hasActiveMediaOrRecommendation()
206         val isMediaShowingInStack =
207             isMediaShowing && !splitShadeStateController.shouldUseSplitNotificationShade(resources)
208 
209         log { "\tGet maxNotifWithoutSavingSpace ---" }
210         val maxNotifWithoutSavingSpace =
211             stackHeightSequence.lastIndexWhile { heightResult ->
212                 allowedByPolicy(heightResult) &&
213                     canStackFitInSpace(
214                         heightResult,
215                         notifSpace = notifSpace,
216                         shelfSpace = shelfSpace,
217                     ) == FitResult.FIT
218             }
219 
220         // How many notifications we can show at heightWithoutLockscreenConstraints
221         val minCountAtHeightWithoutConstraints = if (isMediaShowingInStack) 2 else 1
222         log {
223             "\t---maxNotifWithoutSavingSpace=$maxNotifWithoutSavingSpace " +
224                 "isMediaShowing=$isMediaShowing" +
225                 "isMediaShowingInStack=$isMediaShowingInStack" +
226                 "minCountAtHeightWithoutConstraints=$minCountAtHeightWithoutConstraints"
227         }
228         log { "\n" }
229 
230         var maxNotifications: Int
231         if (maxNotifWithoutSavingSpace >= minCountAtHeightWithoutConstraints) {
232             saveSpaceOnLockscreen = false
233             maxNotifications = maxNotifWithoutSavingSpace
234             log {
235                 "\tDo NOT save space. maxNotifications=maxNotifWithoutSavingSpace=$maxNotifications"
236             }
237         } else {
238             log { "\tSAVE space ---" }
239             saveSpaceOnLockscreen = true
240             maxNotifications =
241                 stackHeightSequence.lastIndexWhile { heightResult ->
242                     allowedByPolicy(heightResult) &&
243                         canStackFitInSpace(
244                             heightResult,
245                             notifSpace = notifSpace,
246                             shelfSpace = shelfSpace,
247                         ) != FitResult.NO_FIT
248                 }
249             log { "\t--- maxNotifications=$maxNotifications" }
250         }
251 
252         // Must update views immediately to avoid mismatches between initial HUN layout height
253         // and the height adapted to lockscreen space constraints, which causes jump cuts.
254         stack.showableChildren().toList().forEach { currentNotification ->
255             run {
256                 if (currentNotification is ExpandableNotificationRow) {
257                     currentNotification.saveSpaceOnLockscreen = saveSpaceOnLockscreen
258                 }
259             }
260         }
261 
262         if (onLockscreen()) {
263             val increaseMaxForMedia = maxNotificationsExcludesMedia && isMediaShowingInStack
264             val lockscreenMax = maxKeyguardNotifications.safeIncrementIf(increaseMaxForMedia)
265             maxNotifications = min(lockscreenMax, maxNotifications)
266         }
267 
268         // Could be < 0 if the space available is less than the shelf size. Returns 0 in this case.
269         maxNotifications = max(0, maxNotifications)
270         log {
271             val sequence = if (SPEW) " stackHeightSequence=${stackHeightSequence.toList()}" else ""
272             "--- computeMaxKeyguardNotifications(" +
273                 " notifSpace=$notifSpace" +
274                 " shelfSpace=$shelfSpace" +
275                 " shelfHeight=$shelfHeight) -> $maxNotifications$sequence"
276         }
277         log { "\n" }
278         return maxNotifications
279     }
280 
281     /**
282      * Given the [maxNotifs] constraint, calculates the height of the
283      * [NotificationStackScrollLayout]. This might or might not be in keyguard.
284      *
285      * @param stack stack containing notifications as children.
286      * @param maxNotifs Maximum number of notifications. When reached, the others will go into the
287      *   shelf.
288      * @param shelfHeight height of the shelf, without any padding. It might be zero.
289      * @return height of the stack, including shelf height, if needed.
290      */
291     fun computeHeight(
292         stack: NotificationStackScrollLayout,
293         maxNotifs: Int,
294         shelfHeight: Float,
295     ): Float {
296         log { "\n" }
297         log { "computeHeight ---" }
298 
299         val stackHeightSequence = computeHeightPerNotificationLimit(stack, shelfHeight)
300 
301         val (notifsHeight, notifsHeightSavingSpace, shelfHeightWithSpaceBefore) =
302             stackHeightSequence.elementAtOrElse(maxNotifs) {
303                 stackHeightSequence.last() // Height with all notifications visible.
304             }
305 
306         var height: Float
307         if (saveSpaceOnLockscreen) {
308             height = notifsHeightSavingSpace + shelfHeightWithSpaceBefore
309             log {
310                 "--- computeHeight(maxNotifs=$maxNotifs, shelfHeight=$shelfHeight)" +
311                     " -> $height=($notifsHeightSavingSpace+$shelfHeightWithSpaceBefore)," +
312                     " | saveSpaceOnLockscreen=$saveSpaceOnLockscreen"
313             }
314         } else {
315             height = notifsHeight + shelfHeightWithSpaceBefore
316             log {
317                 "--- computeHeight(maxNotifs=$maxNotifs, shelfHeight=$shelfHeight)" +
318                     " -> $height=($notifsHeight+$shelfHeightWithSpaceBefore)" +
319                     " | saveSpaceOnLockscreen=$saveSpaceOnLockscreen"
320             }
321         }
322         return height
323     }
324 
325     private enum class FitResult {
326         FIT,
327         FIT_IF_SAVE_SPACE,
328         NO_FIT,
329     }
330 
331     data class SpaceNeeded(
332         // Float height of spaceNeeded when showing heads up layout for FSI HUNs.
333         val whenEnoughSpace: Float,
334 
335         // Float height of space needed when showing collapsed layout for FSI HUNs.
336         val whenSavingSpace: Float,
337     )
338 
339     private data class StackHeight(
340         // Float height with ith max notifications (not including shelf)
341         val notifsHeight: Float,
342 
343         // Float height with ith max notifications
344         // (not including shelf, using collapsed layout for FSI HUN)
345         val notifsHeightSavingSpace: Float,
346 
347         // Float height of shelf (0 if shelf is not showing), and space before the shelf that
348         // changes during the lockscreen <=> full shade transition.
349         val shelfHeightWithSpaceBefore: Float,
350 
351         /** Whether the stack should actually be forced into the shelf before this height. */
352         val shouldForceIntoShelf: Boolean,
353     )
354 
355     private suspend fun trackLockScreenNotificationMinimalismSettingChanges() {
356         if (NotificationMinimalism.isUnexpectedlyInLegacyMode()) return
357         seenNotificationsInteractor.isLockScreenNotificationMinimalismEnabled().collectLatest {
358             if (it != minimalismSettingEnabled) {
359                 minimalismSettingEnabled = it
360             }
361             Log.i(TAG, "minimalismSettingEnabled: $minimalismSettingEnabled")
362         }
363     }
364 
365     private fun computeHeightPerNotificationLimit(
366         stack: NotificationStackScrollLayout,
367         shelfHeight: Float,
368     ): Sequence<StackHeight> = sequence {
369         val children = stack.showableChildren().toList()
370         var notifications = 0f
371         var notifsWithCollapsedHun = 0f
372         var previous: ExpandableView? = null
373         val onLockscreen = onLockscreen()
374 
375         val counter = if (limitLockScreenToOneImportant) BucketTypeCounter() else null
376 
377         // Only shelf. This should never happen, since we allow 1 view minimum (EmptyViewState).
378         yield(
379             StackHeight(
380                 notifsHeight = 0f,
381                 notifsHeightSavingSpace = 0f,
382                 shelfHeightWithSpaceBefore = shelfHeight,
383                 shouldForceIntoShelf = false,
384             )
385         )
386 
387         children.forEachIndexed { i, currentNotification ->
388             val space = getSpaceNeeded(currentNotification, i, previous, stack, onLockscreen)
389             notifications += space.whenEnoughSpace
390             notifsWithCollapsedHun += space.whenSavingSpace
391 
392             previous = currentNotification
393 
394             val shelfWithSpaceBefore =
395                 if (i == children.lastIndex) {
396                     0f // No shelf needed.
397                 } else {
398                     val firstViewInShelfIndex = i + 1
399                     val spaceBeforeShelf =
400                         calculateGapAndDividerHeight(
401                             stack,
402                             previous = currentNotification,
403                             current = children[firstViewInShelfIndex],
404                             currentIndex = firstViewInShelfIndex,
405                         )
406                     spaceBeforeShelf + shelfHeight
407                 }
408 
409             if (counter != null) {
410                 if (NotificationBundleUi.isEnabled) {
411                     val entryAdapter =
412                         (currentNotification as? ExpandableNotificationRow)?.entryAdapter
413                     counter.incrementForBucket(entryAdapter?.sectionBucket)
414                 } else {
415                     val entry = (currentNotification as? ExpandableNotificationRow)?.entryLegacy
416                     counter.incrementForBucket(entry?.bucket)
417                 }
418             }
419 
420             log {
421                 "\tcomputeHeightPerNotificationLimit i=$i notifs=$notifications " +
422                     "notifsHeightSavingSpace=$notifsWithCollapsedHun" +
423                     " shelfWithSpaceBefore=$shelfWithSpaceBefore" +
424                     " limitLockScreenToOneImportant: $limitLockScreenToOneImportant"
425             }
426             yield(
427                 StackHeight(
428                     notifsHeight = notifications,
429                     notifsHeightSavingSpace = notifsWithCollapsedHun,
430                     shelfHeightWithSpaceBefore = shelfWithSpaceBefore,
431                     shouldForceIntoShelf = counter?.shouldForceIntoShelf() ?: false,
432                 )
433             )
434         }
435     }
436 
437     fun updateResources() {
438         maxKeyguardNotifications =
439             infiniteIfNegative(resources.getInteger(R.integer.keyguard_max_notification_count))
440         maxNotificationsExcludesMedia = NotificationMinimalism.isEnabled
441 
442         dividerHeight =
443             max(1f, resources.getDimensionPixelSize(R.dimen.notification_divider_height).toFloat())
444     }
445 
446     private val NotificationStackScrollLayout.childrenSequence: Sequence<ExpandableView>
447         get() = children.map { it as ExpandableView }
448 
449     @VisibleForTesting
450     fun onLockscreen(): Boolean {
451         return statusBarStateController.state == KEYGUARD &&
452             lockscreenShadeTransitionController.fractionToShade == 0f
453     }
454 
455     @VisibleForTesting
456     fun getSpaceNeeded(
457         view: ExpandableView,
458         visibleIndex: Int,
459         previousView: ExpandableView?,
460         stack: NotificationStackScrollLayout,
461         onLockscreen: Boolean,
462     ): SpaceNeeded {
463         assert(view.isShowable(onLockscreen))
464 
465         // Must use heightWithoutLockscreenConstraints because intrinsicHeight references
466         // mSaveSpaceOnLockscreen and using intrinsicHeight here will result in stack overflow.
467         val height = view.heightWithoutLockscreenConstraints.toFloat()
468         val gapAndDividerHeight =
469             calculateGapAndDividerHeight(stack, previousView, current = view, visibleIndex)
470         val canPeek = view is ExpandableNotificationRow &&
471                 if (NotificationBundleUi.isEnabled) view.entryAdapter?.canPeek() == true
472                 else view.entryLegacy.isStickyAndNotDemoted
473 
474         var size =
475             if (onLockscreen) {
476                 if (
477                     view is ExpandableNotificationRow &&
478                         (canPeek || view.isPromotedOngoing)
479                 ) {
480                     height
481                 } else {
482                     view.getMinHeight(/* ignoreTemporaryStates= */ true).toFloat()
483                 }
484             } else {
485                 height
486             }
487         size += gapAndDividerHeight
488 
489         var sizeWhenSavingSpace =
490             if (onLockscreen) {
491                 view.getMinHeight(/* ignoreTemporaryStates= */ true).toFloat()
492             } else {
493                 height
494             }
495         sizeWhenSavingSpace += gapAndDividerHeight
496 
497         return SpaceNeeded(size, sizeWhenSavingSpace)
498     }
499 
500     fun dump(pw: PrintWriter, args: Array<out String>) {
501         pw.println("NotificationStackSizeCalculator saveSpaceOnLockscreen=$saveSpaceOnLockscreen")
502         pw.println(
503             "NotificationStackSizeCalculator " +
504                 "limitLockScreenToOneImportant=$limitLockScreenToOneImportant"
505         )
506     }
507 
508     private fun ExpandableView.isShowable(onLockscreen: Boolean): Boolean {
509         if (visibility == GONE || hasNoContentHeight()) return false
510         if (onLockscreen) {
511             when (this) {
512                 is ExpandableNotificationRow -> {
513                     if (!canShowViewOnLockscreen() || isRemoved) {
514                         return false
515                     }
516                 }
517                 is MediaContainerView -> if (intrinsicHeight == 0) return false
518                 else -> return false
519             }
520         }
521         return true
522     }
523 
524     private fun calculateGapAndDividerHeight(
525         stack: NotificationStackScrollLayout,
526         previous: ExpandableView?,
527         current: ExpandableView?,
528         currentIndex: Int,
529     ): Float {
530         if (currentIndex == 0) {
531             return 0f
532         }
533         return stack.calculateGapHeight(previous, current, currentIndex) + dividerHeight
534     }
535 
536     private fun NotificationStackScrollLayout.showableChildren() =
537         this.childrenSequence.filter { it.isShowable(onLockscreen()) }
538 
539     /**
540      * Can a view be shown on the lockscreen when calculating the number of allowed notifications to
541      * show?
542      *
543      * @return `true` if it can be shown.
544      */
545     private fun ExpandableView.canShowViewOnLockscreen(): Boolean {
546         if (hasNoContentHeight()) {
547             return false
548         } else if (visibility == GONE) {
549             return false
550         }
551         return true
552     }
553 
554     private inline fun log(s: () -> String) {
555         if (DEBUG) {
556             Log.d(TAG, s())
557         }
558     }
559 
560     /** Returns infinite when [v] is negative. Useful in case a resource doesn't limit when -1. */
561     private fun infiniteIfNegative(v: Int): Int =
562         if (v < 0) {
563             Int.MAX_VALUE
564         } else {
565             v
566         }
567 
568     private fun Int.safeIncrementIf(condition: Boolean): Int =
569         if (condition && this != Int.MAX_VALUE) {
570             this + 1
571         } else {
572             this
573         }
574 
575     /** Returns the last index where [predicate] returns true, or -1 if it was always false. */
576     private fun <T> Sequence<T>.lastIndexWhile(predicate: (T) -> Boolean): Int =
577         takeWhile(predicate).count() - 1
578 
579     /** Counts the number of notifications for each type of bucket */
580     data class BucketTypeCounter(var ongoing: Int = 0, var important: Int = 0, var other: Int = 0) {
581         fun incrementForBucket(@PriorityBucket bucket: Int?) {
582             when (bucket) {
583                 BUCKET_MEDIA_CONTROLS,
584                 null -> Unit // not counted as notifications at all
585                 BUCKET_TOP_ONGOING -> ongoing++
586                 BUCKET_HEADS_UP -> important++
587                 BUCKET_TOP_UNSEEN -> important++
588                 else -> other++
589             }
590         }
591 
592         fun shouldForceIntoShelf(): Boolean = ongoing > 1 || important > 1 || other > 0
593     }
594 }
595