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