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