1 /* 2 * Copyright (C) 2020 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.collection.coordinator 17 18 import android.app.Notification 19 import android.app.Notification.GROUP_ALERT_SUMMARY 20 import android.util.ArrayMap 21 import android.util.ArraySet 22 import com.android.internal.annotations.VisibleForTesting 23 import com.android.systemui.dagger.qualifiers.Main 24 import com.android.systemui.statusbar.NotificationRemoteInputManager 25 import com.android.systemui.statusbar.notification.NotifPipelineFlags 26 import com.android.systemui.statusbar.notification.collection.GroupEntry 27 import com.android.systemui.statusbar.notification.collection.ListEntry 28 import com.android.systemui.statusbar.notification.collection.NotifPipeline 29 import com.android.systemui.statusbar.notification.collection.NotificationEntry 30 import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope 31 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifComparator 32 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter 33 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner 34 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener 35 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender 36 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender.OnEndLifetimeExtensionCallback 37 import com.android.systemui.statusbar.notification.collection.provider.LaunchFullScreenIntentProvider 38 import com.android.systemui.statusbar.notification.collection.render.NodeController 39 import com.android.systemui.statusbar.notification.dagger.IncomingHeader 40 import com.android.systemui.statusbar.notification.interruption.HeadsUpViewBinder 41 import com.android.systemui.statusbar.notification.interruption.VisualInterruptionDecisionProvider 42 import com.android.systemui.statusbar.notification.logKey 43 import com.android.systemui.statusbar.notification.stack.BUCKET_HEADS_UP 44 import com.android.systemui.statusbar.policy.HeadsUpManager 45 import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener 46 import com.android.systemui.util.concurrency.DelayableExecutor 47 import com.android.systemui.util.time.SystemClock 48 import java.util.function.Consumer 49 import javax.inject.Inject 50 51 /** 52 * Coordinates heads up notification (HUN) interactions with the notification pipeline based on 53 * the HUN state reported by the [HeadsUpManager]. In this class we only consider one 54 * notification, in particular the [HeadsUpManager.getTopEntry], to be HeadsUpping at a 55 * time even though other notifications may be queued to heads up next. 56 * 57 * The current HUN, but not HUNs that are queued to heads up, will be: 58 * - Lifetime extended until it's no longer heads upping. 59 * - Promoted out of its group if it's a child of a group. 60 * - In the HeadsUpCoordinatorSection. Ordering is configured in [NotifCoordinators]. 61 * - Removed from HeadsUpManager if it's removed from the NotificationCollection. 62 * 63 * Note: The inflation callback in [PreparationCoordinator] handles showing HUNs. 64 */ 65 @CoordinatorScope 66 class HeadsUpCoordinator @Inject constructor( 67 private val mLogger: HeadsUpCoordinatorLogger, 68 private val mSystemClock: SystemClock, 69 private val mHeadsUpManager: HeadsUpManager, 70 private val mHeadsUpViewBinder: HeadsUpViewBinder, 71 private val mVisualInterruptionDecisionProvider: VisualInterruptionDecisionProvider, 72 private val mRemoteInputManager: NotificationRemoteInputManager, 73 private val mLaunchFullScreenIntentProvider: LaunchFullScreenIntentProvider, 74 private val mFlags: NotifPipelineFlags, 75 @IncomingHeader private val mIncomingHeaderController: NodeController, 76 @Main private val mExecutor: DelayableExecutor 77 ) : Coordinator { 78 private val mEntriesBindingUntil = ArrayMap<String, Long>() 79 private val mEntriesUpdateTimes = ArrayMap<String, Long>() 80 private val mFSIUpdateCandidates = ArrayMap<String, Long>() 81 private var mEndLifetimeExtension: OnEndLifetimeExtensionCallback? = null 82 private lateinit var mNotifPipeline: NotifPipeline 83 private var mNow: Long = -1 84 private val mPostedEntries = LinkedHashMap<String, PostedEntry>() 85 86 // notifs we've extended the lifetime for with cancellation callbacks 87 private val mNotifsExtendingLifetime = ArrayMap<NotificationEntry, Runnable?>() 88 89 override fun attach(pipeline: NotifPipeline) { 90 mNotifPipeline = pipeline 91 mHeadsUpManager.addListener(mOnHeadsUpChangedListener) 92 pipeline.addCollectionListener(mNotifCollectionListener) 93 pipeline.addOnBeforeTransformGroupsListener(::onBeforeTransformGroups) 94 pipeline.addOnBeforeFinalizeFilterListener(::onBeforeFinalizeFilter) 95 pipeline.addPromoter(mNotifPromoter) 96 pipeline.addNotificationLifetimeExtender(mLifetimeExtender) 97 mRemoteInputManager.addActionPressListener(mActionPressListener) 98 } 99 100 private fun onHeadsUpViewBound(entry: NotificationEntry) { 101 mHeadsUpManager.showNotification(entry) 102 mEntriesBindingUntil.remove(entry.key) 103 } 104 105 /** 106 * Once the pipeline starts running, we can look through posted entries and quickly process 107 * any that don't have groups, and thus will never gave a group alert edge case. 108 */ 109 fun onBeforeTransformGroups(list: List<ListEntry>) { 110 mNow = mSystemClock.currentTimeMillis() 111 if (mPostedEntries.isEmpty()) { 112 return 113 } 114 // Process all non-group adds/updates 115 mHeadsUpManager.modifyHuns { hunMutator -> 116 mPostedEntries.values.toList().forEach { posted -> 117 if (!posted.entry.sbn.isGroup) { 118 handlePostedEntry(posted, hunMutator, "non-group") 119 mPostedEntries.remove(posted.key) 120 } 121 } 122 } 123 } 124 125 /** 126 * Once we have a nearly final shade list (not including what's pruned for inflation reasons), 127 * we know that stability and [NotifPromoter]s have been applied, so we can use the location of 128 * notifications in this list to determine what kind of group alert behavior should happen. 129 */ 130 fun onBeforeFinalizeFilter(list: List<ListEntry>) = mHeadsUpManager.modifyHuns { hunMutator -> 131 // Nothing to do if there are no other adds/updates 132 if (mPostedEntries.isEmpty()) { 133 return@modifyHuns 134 } 135 // Calculate a bunch of information about the logical group and the locations of group 136 // entries in the nearly-finalized shade list. These may be used in the per-group loop. 137 val postedEntriesByGroup = mPostedEntries.values.groupBy { it.entry.sbn.groupKey } 138 val logicalMembersByGroup = mNotifPipeline.allNotifs.asSequence() 139 .filter { postedEntriesByGroup.contains(it.sbn.groupKey) } 140 .groupBy { it.sbn.groupKey } 141 val groupLocationsByKey: Map<String, GroupLocation> by lazy { getGroupLocationsByKey(list) } 142 mLogger.logEvaluatingGroups(postedEntriesByGroup.size) 143 // For each group, determine which notification(s) for a group should alert. 144 postedEntriesByGroup.forEach { (groupKey, postedEntries) -> 145 // get and classify the logical members 146 val logicalMembers = logicalMembersByGroup[groupKey] ?: emptyList() 147 val logicalSummary = logicalMembers.find { it.sbn.notification.isGroupSummary } 148 149 // Report the start of this group's evaluation 150 mLogger.logEvaluatingGroup(groupKey, postedEntries.size, logicalMembers.size) 151 152 // If there is no logical summary, then there is no alert to transfer 153 if (logicalSummary == null) { 154 postedEntries.forEach { 155 handlePostedEntry(it, hunMutator, scenario = "logical-summary-missing") 156 } 157 return@forEach 158 } 159 160 // If summary isn't wanted to be heads up, then there is no alert to transfer 161 if (!isGoingToShowHunStrict(logicalSummary)) { 162 postedEntries.forEach { 163 handlePostedEntry(it, hunMutator, scenario = "logical-summary-not-alerting") 164 } 165 return@forEach 166 } 167 168 // The group is alerting! Overall goals: 169 // - Maybe transfer its alert to a child 170 // - Also let any/all newly alerting children still alert 171 var childToReceiveParentAlert: NotificationEntry? 172 var targetType = "undefined" 173 174 // If the parent is alerting, always look at the posted notification with the newest 175 // 'when', and if it is isolated with GROUP_ALERT_SUMMARY, then it should receive the 176 // parent's alert. 177 childToReceiveParentAlert = 178 findAlertOverride(postedEntries, groupLocationsByKey::getLocation) 179 if (childToReceiveParentAlert != null) { 180 targetType = "alertOverride" 181 } 182 183 // If the summary is Detached and we have not picked a receiver of the alert, then we 184 // need to look for the best child to alert in place of the summary. 185 val isSummaryAttached = groupLocationsByKey.contains(logicalSummary.key) 186 if (!isSummaryAttached && childToReceiveParentAlert == null) { 187 childToReceiveParentAlert = 188 findBestTransferChild(logicalMembers, groupLocationsByKey::getLocation) 189 if (childToReceiveParentAlert != null) { 190 targetType = "bestChild" 191 } 192 } 193 194 // If there is no child to receive the parent alert, then just handle the posted entries 195 // and return. 196 if (childToReceiveParentAlert == null) { 197 postedEntries.forEach { 198 handlePostedEntry(it, hunMutator, scenario = "no-transfer-target") 199 } 200 return@forEach 201 } 202 203 // At this point we just need to initiate the transfer 204 val summaryUpdate = mPostedEntries[logicalSummary.key] 205 206 // Because we now know for certain that some child is going to alert for this summary 207 // (as we have found a child to transfer the alert to), mark the group as having 208 // interrupted. This will allow us to know in the future that the "should heads up" 209 // state of this group has already been handled, just not via the summary entry itself. 210 logicalSummary.setInterruption() 211 mLogger.logSummaryMarkedInterrupted(logicalSummary.key, childToReceiveParentAlert.key) 212 213 // If the summary was not attached, then remove the alert from the detached summary. 214 // Otherwise we can simply ignore its posted update. 215 if (!isSummaryAttached) { 216 val summaryUpdateForRemoval = summaryUpdate?.also { 217 it.shouldHeadsUpEver = false 218 } ?: PostedEntry( 219 logicalSummary, 220 wasAdded = false, 221 wasUpdated = false, 222 shouldHeadsUpEver = false, 223 shouldHeadsUpAgain = false, 224 isAlerting = mHeadsUpManager.isAlerting(logicalSummary.key), 225 isBinding = isEntryBinding(logicalSummary), 226 ) 227 // If we transfer the alert and the summary isn't even attached, that means we 228 // should ensure the summary is no longer alerting, so we remove it here. 229 handlePostedEntry( 230 summaryUpdateForRemoval, 231 hunMutator, 232 scenario = "detached-summary-remove-alert") 233 } else if (summaryUpdate != null) { 234 mLogger.logPostedEntryWillNotEvaluate( 235 summaryUpdate, 236 reason = "attached-summary-transferred") 237 } 238 239 // Handle all posted entries -- if the child receiving the parent's alert is in the 240 // list, then set its flags to ensure it alerts. 241 var didAlertChildToReceiveParentAlert = false 242 postedEntries.asSequence() 243 .filter { it.key != logicalSummary.key } 244 .forEach { postedEntry -> 245 if (childToReceiveParentAlert.key == postedEntry.key) { 246 // Update the child's posted update so that it 247 postedEntry.shouldHeadsUpEver = true 248 postedEntry.shouldHeadsUpAgain = true 249 handlePostedEntry( 250 postedEntry, 251 hunMutator, 252 scenario = "child-alert-transfer-target-$targetType") 253 didAlertChildToReceiveParentAlert = true 254 } else { 255 handlePostedEntry( 256 postedEntry, 257 hunMutator, 258 scenario = "child-alert-non-target") 259 } 260 } 261 262 // If the child receiving the alert was not updated on this tick (which can happen in a 263 // standard alert transfer scenario), then construct an update so that we can apply it. 264 if (!didAlertChildToReceiveParentAlert) { 265 val posted = PostedEntry( 266 childToReceiveParentAlert, 267 wasAdded = false, 268 wasUpdated = false, 269 shouldHeadsUpEver = true, 270 shouldHeadsUpAgain = true, 271 isAlerting = mHeadsUpManager.isAlerting(childToReceiveParentAlert.key), 272 isBinding = isEntryBinding(childToReceiveParentAlert), 273 ) 274 handlePostedEntry( 275 posted, 276 hunMutator, 277 scenario = "non-posted-child-alert-transfer-target-$targetType") 278 } 279 } 280 // After this method runs, all posted entries should have been handled (or skipped). 281 mPostedEntries.clear() 282 283 // Also take this opportunity to clean up any stale entry update times 284 cleanUpEntryTimes() 285 } 286 287 /** 288 * Find the posted child with the newest when, and return it if it is isolated and has 289 * GROUP_ALERT_SUMMARY so that it can be alerted. 290 */ 291 private fun findAlertOverride( 292 postedEntries: List<PostedEntry>, 293 locationLookupByKey: (String) -> GroupLocation, 294 ): NotificationEntry? = postedEntries.asSequence() 295 .filter { posted -> !posted.entry.sbn.notification.isGroupSummary } 296 .sortedBy { posted -> -posted.entry.sbn.notification.`when` } 297 .firstOrNull() 298 ?.let { posted -> 299 posted.entry.takeIf { entry -> 300 locationLookupByKey(entry.key) == GroupLocation.Isolated && 301 entry.sbn.notification.groupAlertBehavior == GROUP_ALERT_SUMMARY 302 } 303 } 304 305 /** 306 * Of children which are attached, look for the child to receive the notification: 307 * First prefer children which were updated, then looking for the ones with the newest 'when' 308 */ 309 private fun findBestTransferChild( 310 logicalMembers: List<NotificationEntry>, 311 locationLookupByKey: (String) -> GroupLocation, 312 ): NotificationEntry? = logicalMembers.asSequence() 313 .filter { !it.sbn.notification.isGroupSummary } 314 .filter { locationLookupByKey(it.key) != GroupLocation.Detached } 315 .sortedWith(compareBy( 316 { !mPostedEntries.contains(it.key) }, 317 { -it.sbn.notification.`when` }, 318 )) 319 .firstOrNull() 320 321 private fun getGroupLocationsByKey(list: List<ListEntry>): Map<String, GroupLocation> = 322 mutableMapOf<String, GroupLocation>().also { map -> 323 list.forEach { topLevelEntry -> 324 when (topLevelEntry) { 325 is NotificationEntry -> map[topLevelEntry.key] = GroupLocation.Isolated 326 is GroupEntry -> { 327 topLevelEntry.summary?.let { summary -> 328 map[summary.key] = GroupLocation.Summary 329 } 330 topLevelEntry.children.forEach { child -> 331 map[child.key] = GroupLocation.Child 332 } 333 } 334 else -> error("unhandled type $topLevelEntry") 335 } 336 } 337 } 338 339 private fun handlePostedEntry(posted: PostedEntry, hunMutator: HunMutator, scenario: String) { 340 mLogger.logPostedEntryWillEvaluate(posted, scenario) 341 if (posted.wasAdded) { 342 if (posted.shouldHeadsUpEver) { 343 bindForAsyncHeadsUp(posted) 344 } 345 } else { 346 if (posted.isHeadsUpAlready) { 347 // NOTE: This might be because we're alerting (i.e. tracked by HeadsUpManager) OR 348 // it could be because we're binding, and that will affect the next step. 349 if (posted.shouldHeadsUpEver) { 350 // If alerting, we need to post an update. Otherwise we're still binding, 351 // and we can just let that finish. 352 if (posted.isAlerting) { 353 hunMutator.updateNotification(posted.key, posted.shouldHeadsUpAgain) 354 } 355 } else { 356 if (posted.isAlerting) { 357 // We don't want this to be interrupting anymore, let's remove it 358 hunMutator.removeNotification(posted.key, false /*removeImmediately*/) 359 } else { 360 // Don't let the bind finish 361 cancelHeadsUpBind(posted.entry) 362 } 363 } 364 } else if (posted.shouldHeadsUpEver && posted.shouldHeadsUpAgain) { 365 // This notification was updated to be heads up, show it! 366 bindForAsyncHeadsUp(posted) 367 } 368 } 369 } 370 371 private fun cancelHeadsUpBind(entry: NotificationEntry) { 372 mEntriesBindingUntil.remove(entry.key) 373 mHeadsUpViewBinder.abortBindCallback(entry) 374 } 375 376 private fun bindForAsyncHeadsUp(posted: PostedEntry) { 377 // TODO: Add a guarantee to bindHeadsUpView of some kind of callback if the bind is 378 // cancelled so that we don't need to have this sad timeout hack. 379 mEntriesBindingUntil[posted.key] = mNow + BIND_TIMEOUT 380 mHeadsUpViewBinder.bindHeadsUpView(posted.entry, this::onHeadsUpViewBound) 381 } 382 383 private val mNotifCollectionListener = object : NotifCollectionListener { 384 /** 385 * Notification was just added and if it should heads up, bind the view and then show it. 386 */ 387 override fun onEntryAdded(entry: NotificationEntry) { 388 // First check whether this notification should launch a full screen intent, and 389 // launch it if needed. 390 val fsiDecision = 391 mVisualInterruptionDecisionProvider.makeUnloggedFullScreenIntentDecision(entry) 392 mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(fsiDecision) 393 if (fsiDecision.shouldInterrupt) { 394 mLaunchFullScreenIntentProvider.launchFullScreenIntent(entry) 395 } else if (fsiDecision.wouldInterruptWithoutDnd) { 396 // If DND was the only reason this entry was suppressed, note it for potential 397 // reconsideration on later ranking updates. 398 addForFSIReconsideration(entry, mSystemClock.currentTimeMillis()) 399 } 400 401 // makeAndLogHeadsUpDecision includes check for whether this notification should be 402 // filtered 403 val shouldHeadsUpEver = 404 mVisualInterruptionDecisionProvider.makeAndLogHeadsUpDecision(entry).shouldInterrupt 405 mPostedEntries[entry.key] = PostedEntry( 406 entry, 407 wasAdded = true, 408 wasUpdated = false, 409 shouldHeadsUpEver = shouldHeadsUpEver, 410 shouldHeadsUpAgain = true, 411 isAlerting = false, 412 isBinding = false, 413 ) 414 415 // Record the last updated time for this key 416 setUpdateTime(entry, mSystemClock.currentTimeMillis()) 417 } 418 419 /** 420 * Notification could've updated to be heads up or not heads up. Even if it did update to 421 * heads up, if the notification specified that it only wants to alert once, don't heads 422 * up again. 423 */ 424 override fun onEntryUpdated(entry: NotificationEntry) { 425 val shouldHeadsUpEver = 426 mVisualInterruptionDecisionProvider.makeAndLogHeadsUpDecision(entry).shouldInterrupt 427 val shouldHeadsUpAgain = shouldHunAgain(entry) 428 val isAlerting = mHeadsUpManager.isAlerting(entry.key) 429 val isBinding = isEntryBinding(entry) 430 val posted = mPostedEntries.compute(entry.key) { _, value -> 431 value?.also { update -> 432 update.wasUpdated = true 433 update.shouldHeadsUpEver = shouldHeadsUpEver 434 update.shouldHeadsUpAgain = update.shouldHeadsUpAgain || shouldHeadsUpAgain 435 update.isAlerting = isAlerting 436 update.isBinding = isBinding 437 } ?: PostedEntry( 438 entry, 439 wasAdded = false, 440 wasUpdated = true, 441 shouldHeadsUpEver = shouldHeadsUpEver, 442 shouldHeadsUpAgain = shouldHeadsUpAgain, 443 isAlerting = isAlerting, 444 isBinding = isBinding, 445 ) 446 } 447 // Handle cancelling alerts here, rather than in the OnBeforeFinalizeFilter, so that 448 // work can be done before the ShadeListBuilder is run. This prevents re-entrant 449 // behavior between this Coordinator, HeadsUpManager, and VisualStabilityManager. 450 if (posted?.shouldHeadsUpEver == false) { 451 if (posted.isAlerting) { 452 // We don't want this to be interrupting anymore, let's remove it 453 mHeadsUpManager.removeNotification(posted.key, false /*removeImmediately*/) 454 } else if (posted.isBinding) { 455 // Don't let the bind finish 456 cancelHeadsUpBind(posted.entry) 457 } 458 } 459 460 // Update last updated time for this entry 461 setUpdateTime(entry, mSystemClock.currentTimeMillis()) 462 } 463 464 /** 465 * Stop alerting HUNs that are removed from the notification collection 466 */ 467 override fun onEntryRemoved(entry: NotificationEntry, reason: Int) { 468 mPostedEntries.remove(entry.key) 469 mEntriesUpdateTimes.remove(entry.key) 470 cancelHeadsUpBind(entry) 471 val entryKey = entry.key 472 if (mHeadsUpManager.isAlerting(entryKey)) { 473 // TODO: This should probably know the RemoteInputCoordinator's conditions, 474 // or otherwise reference that coordinator's state, rather than replicate its logic 475 val removeImmediatelyForRemoteInput = (mRemoteInputManager.isSpinning(entryKey) && 476 !NotificationRemoteInputManager.FORCE_REMOTE_INPUT_HISTORY) 477 mHeadsUpManager.removeNotification(entry.key, removeImmediatelyForRemoteInput) 478 } 479 } 480 481 override fun onEntryCleanUp(entry: NotificationEntry) { 482 mHeadsUpViewBinder.abortBindCallback(entry) 483 } 484 485 /** 486 * Identify notifications whose heads-up state changes when the notification rankings are 487 * updated, and have those changed notifications alert if necessary. 488 * 489 * This method will occur after any operations in onEntryAdded or onEntryUpdated, so any 490 * handling of ranking changes needs to take into account that we may have just made a 491 * PostedEntry for some of these notifications. 492 */ 493 override fun onRankingApplied() { 494 // Because a ranking update may cause some notifications that are no longer (or were 495 // never) in mPostedEntries to need to alert, we need to check every notification 496 // known to the pipeline. 497 for (entry in mNotifPipeline.allNotifs) { 498 // Only consider entries that are recent enough, since we want to apply a fairly 499 // strict threshold for when an entry should be updated via only ranking and not an 500 // app-provided notification update. 501 if (!isNewEnoughForRankingUpdate(entry)) continue 502 503 // The only entries we consider alerting for here are entries that have never 504 // interrupted and that now say they should heads up or FSI; if they've alerted in 505 // the past, we don't want to incorrectly alert a second time if there wasn't an 506 // explicit notification update. 507 if (entry.hasInterrupted()) continue 508 509 // Before potentially allowing heads-up, check for any candidates for a FSI launch. 510 // Any entry that is a candidate meets two criteria: 511 // - was suppressed from FSI launch only by a DND suppression 512 // - is within the recency window for reconsideration 513 // If any of these entries are no longer suppressed, launch the FSI now. 514 if (isCandidateForFSIReconsideration(entry)) { 515 val decision = 516 mVisualInterruptionDecisionProvider.makeUnloggedFullScreenIntentDecision( 517 entry 518 ) 519 if (decision.shouldInterrupt) { 520 // Log both the launch of the full screen and also that this was via a 521 // ranking update, and finally revoke candidacy for FSI reconsideration 522 mLogger.logEntryUpdatedToFullScreen(entry.key, decision.logReason) 523 mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(decision) 524 mLaunchFullScreenIntentProvider.launchFullScreenIntent(entry) 525 mFSIUpdateCandidates.remove(entry.key) 526 527 // if we launch the FSI then this is no longer a candidate for HUN 528 continue 529 } else if (decision.wouldInterruptWithoutDnd) { 530 // decision has not changed; no need to log 531 } else { 532 // some other condition is now blocking FSI; log that and revoke candidacy 533 // for FSI reconsideration 534 mLogger.logEntryDisqualifiedFromFullScreen(entry.key, decision.logReason) 535 mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(decision) 536 mFSIUpdateCandidates.remove(entry.key) 537 } 538 } 539 540 // The cases where we should consider this notification to be updated: 541 // - if this entry is not present in PostedEntries, and is now in a shouldHeadsUp 542 // state 543 // - if it is present in PostedEntries and the previous state of shouldHeadsUp 544 // differs from the updated one 545 val decision = 546 mVisualInterruptionDecisionProvider.makeUnloggedHeadsUpDecision(entry) 547 val shouldHeadsUpEver = decision.shouldInterrupt 548 val postedShouldHeadsUpEver = mPostedEntries[entry.key]?.shouldHeadsUpEver ?: false 549 val shouldUpdateEntry = postedShouldHeadsUpEver != shouldHeadsUpEver 550 551 if (shouldUpdateEntry) { 552 mLogger.logEntryUpdatedByRanking( 553 entry.key, 554 shouldHeadsUpEver, 555 decision.logReason 556 ) 557 onEntryUpdated(entry) 558 } 559 } 560 } 561 } 562 563 /** 564 * Checks whether an update for a notification warrants an alert for the user. 565 */ 566 private fun shouldHunAgain(entry: NotificationEntry): Boolean { 567 return (!entry.hasInterrupted() || 568 (entry.sbn.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE) == 0) 569 } 570 571 /** 572 * Sets the updated time for the given entry to the specified time. 573 */ 574 @VisibleForTesting 575 fun setUpdateTime(entry: NotificationEntry, time: Long) { 576 mEntriesUpdateTimes[entry.key] = time 577 } 578 579 /** 580 * Add the entry to the list of entries potentially considerable for FSI ranking update, where 581 * the provided time is the time the entry was added. 582 */ 583 @VisibleForTesting 584 fun addForFSIReconsideration(entry: NotificationEntry, time: Long) { 585 mFSIUpdateCandidates[entry.key] = time 586 } 587 588 /** 589 * Checks whether the entry is new enough to be updated via ranking update. 590 * We want to avoid updating an entry too long after it was originally posted/updated when we're 591 * only reacting to a ranking change, as relevant ranking updates are expected to come in 592 * fairly soon after the posting of a notification. 593 */ 594 private fun isNewEnoughForRankingUpdate(entry: NotificationEntry): Boolean { 595 // If we don't have an update time for this key, default to "too old" 596 if (!mEntriesUpdateTimes.containsKey(entry.key)) return false 597 598 val updateTime = mEntriesUpdateTimes[entry.key] ?: return false 599 return (mSystemClock.currentTimeMillis() - updateTime) <= MAX_RANKING_UPDATE_DELAY_MS 600 } 601 602 /** 603 * Checks whether the entry is present new enough for reconsideration for full screen launch. 604 * The time window is the same as for ranking update, but this doesn't allow a potential update 605 * to an entry with full screen intent to count for timing purposes. 606 */ 607 private fun isCandidateForFSIReconsideration(entry: NotificationEntry): Boolean { 608 val addedTime = mFSIUpdateCandidates[entry.key] ?: return false 609 return (mSystemClock.currentTimeMillis() - addedTime) <= MAX_RANKING_UPDATE_DELAY_MS 610 } 611 612 private fun cleanUpEntryTimes() { 613 // Because we won't update entries that are older than this amount of time anyway, clean 614 // up any entries that are too old to notify from both the general and FSI specific lists. 615 616 // Anything newer than this time is still within the window. 617 val timeThreshold = mSystemClock.currentTimeMillis() - MAX_RANKING_UPDATE_DELAY_MS 618 619 val toRemove = ArraySet<String>() 620 for ((key, updateTime) in mEntriesUpdateTimes) { 621 if (updateTime == null || timeThreshold > updateTime) { 622 toRemove.add(key) 623 } 624 } 625 mEntriesUpdateTimes.removeAll(toRemove) 626 627 val toRemoveForFSI = ArraySet<String>() 628 for ((key, addedTime) in mFSIUpdateCandidates) { 629 if (addedTime == null || timeThreshold > addedTime) { 630 toRemoveForFSI.add(key) 631 } 632 } 633 mFSIUpdateCandidates.removeAll(toRemoveForFSI) 634 } 635 636 /** 637 * When an action is pressed on a notification, make sure we don't lifetime-extend it in the 638 * future by informing the HeadsUpManager, and make sure we don't keep lifetime-extending it if 639 * we already are. 640 * 641 * @see HeadsUpManager.setUserActionMayIndirectlyRemove 642 * @see HeadsUpManager.canRemoveImmediately 643 */ 644 private val mActionPressListener = Consumer<NotificationEntry> { entry -> 645 mHeadsUpManager.setUserActionMayIndirectlyRemove(entry) 646 mExecutor.execute { endNotifLifetimeExtensionIfExtended(entry) } 647 } 648 649 private val mLifetimeExtender = object : NotifLifetimeExtender { 650 override fun getName() = TAG 651 652 override fun setCallback(callback: OnEndLifetimeExtensionCallback) { 653 mEndLifetimeExtension = callback 654 } 655 656 override fun maybeExtendLifetime(entry: NotificationEntry, reason: Int): Boolean { 657 if (mHeadsUpManager.canRemoveImmediately(entry.key)) { 658 return false 659 } 660 if (isSticky(entry)) { 661 val removeAfterMillis = mHeadsUpManager.getEarliestRemovalTime(entry.key) 662 mNotifsExtendingLifetime[entry] = mExecutor.executeDelayed({ 663 mHeadsUpManager.removeNotification(entry.key, /* releaseImmediately */ true) 664 }, removeAfterMillis) 665 } else { 666 mExecutor.execute { 667 mHeadsUpManager.removeNotification(entry.key, /* releaseImmediately */ false) 668 } 669 mNotifsExtendingLifetime[entry] = null 670 } 671 return true 672 } 673 674 override fun cancelLifetimeExtension(entry: NotificationEntry) { 675 mNotifsExtendingLifetime.remove(entry)?.run() 676 } 677 } 678 679 private val mNotifPromoter = object : NotifPromoter(TAG) { 680 override fun shouldPromoteToTopLevel(entry: NotificationEntry): Boolean = 681 isGoingToShowHunNoRetract(entry) 682 } 683 684 val sectioner = object : NotifSectioner("HeadsUp", BUCKET_HEADS_UP) { 685 override fun isInSection(entry: ListEntry): Boolean = 686 // TODO: This check won't notice if a child of the group is going to HUN... 687 isGoingToShowHunNoRetract(entry) 688 689 override fun getComparator(): NotifComparator { 690 return object : NotifComparator("HeadsUp") { 691 override fun compare(o1: ListEntry, o2: ListEntry): Int = 692 mHeadsUpManager.compare(o1.representativeEntry, o2.representativeEntry) 693 } 694 } 695 696 override fun getHeaderNodeController(): NodeController? = 697 // TODO: remove SHOW_ALL_SECTIONS, this redundant method, and mIncomingHeaderController 698 if (RankingCoordinator.SHOW_ALL_SECTIONS) mIncomingHeaderController else null 699 } 700 701 private val mOnHeadsUpChangedListener = object : OnHeadsUpChangedListener { 702 override fun onHeadsUpStateChanged(entry: NotificationEntry, isHeadsUp: Boolean) { 703 if (!isHeadsUp) { 704 mNotifPromoter.invalidateList("headsUpEnded: ${entry.logKey}") 705 mHeadsUpViewBinder.unbindHeadsUpView(entry) 706 endNotifLifetimeExtensionIfExtended(entry) 707 } 708 } 709 } 710 711 private fun isSticky(entry: NotificationEntry) = mHeadsUpManager.isSticky(entry.key) 712 713 private fun isEntryBinding(entry: ListEntry): Boolean { 714 val bindingUntil = mEntriesBindingUntil[entry.key] 715 return bindingUntil != null && bindingUntil >= mNow 716 } 717 718 /** 719 * Whether the notification is already alerting or binding so that it can imminently alert 720 */ 721 private fun isAttemptingToShowHun(entry: ListEntry) = 722 mHeadsUpManager.isAlerting(entry.key) || isEntryBinding(entry) 723 724 /** 725 * Whether the notification is already alerting/binding per [isAttemptingToShowHun] OR if it 726 * has been updated so that it should alert this update. This method is permissive because it 727 * returns `true` even if the update would (in isolation of its group) cause the alert to be 728 * retracted. This is important for not retracting transferred group alerts. 729 */ 730 private fun isGoingToShowHunNoRetract(entry: ListEntry) = 731 mPostedEntries[entry.key]?.calculateShouldBeHeadsUpNoRetract ?: isAttemptingToShowHun(entry) 732 733 /** 734 * If the notification has been updated, then whether it should HUN in isolation, otherwise 735 * defers to the already alerting/binding state of [isAttemptingToShowHun]. This method is 736 * strict because any update which would revoke the alert supersedes the current 737 * alerting/binding state. 738 */ 739 private fun isGoingToShowHunStrict(entry: ListEntry) = 740 mPostedEntries[entry.key]?.calculateShouldBeHeadsUpStrict ?: isAttemptingToShowHun(entry) 741 742 private fun endNotifLifetimeExtensionIfExtended(entry: NotificationEntry) { 743 if (mNotifsExtendingLifetime.contains(entry)) { 744 mNotifsExtendingLifetime.remove(entry)?.run() 745 mEndLifetimeExtension?.onEndLifetimeExtension(mLifetimeExtender, entry) 746 } 747 } 748 749 companion object { 750 private const val TAG = "HeadsUpCoordinator" 751 private const val BIND_TIMEOUT = 1000L 752 753 // This value is set to match MAX_SOUND_DELAY_MS in NotificationRecord. 754 private const val MAX_RANKING_UPDATE_DELAY_MS: Long = 2000 755 } 756 757 data class PostedEntry( 758 val entry: NotificationEntry, 759 val wasAdded: Boolean, 760 var wasUpdated: Boolean, 761 var shouldHeadsUpEver: Boolean, 762 var shouldHeadsUpAgain: Boolean, 763 var isAlerting: Boolean, 764 var isBinding: Boolean, 765 ) { 766 val key = entry.key 767 val isHeadsUpAlready: Boolean 768 get() = isAlerting || isBinding 769 val calculateShouldBeHeadsUpStrict: Boolean 770 get() = shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain || isHeadsUpAlready) 771 val calculateShouldBeHeadsUpNoRetract: Boolean 772 get() = isHeadsUpAlready || (shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain)) 773 } 774 } 775 776 private enum class GroupLocation { Detached, Isolated, Summary, Child } 777 778 private fun Map<String, GroupLocation>.getLocation(key: String): GroupLocation = 779 getOrDefault(key, GroupLocation.Detached) 780 781 /** 782 * Invokes the given block with a [HunMutator] that defers all HUN removals. This ensures that the 783 * HeadsUpManager is notified of additions before removals, which prevents a glitch where the 784 * HeadsUpManager temporarily believes that nothing is alerting, causing bad re-entrant behavior. 785 */ 786 private fun <R> HeadsUpManager.modifyHuns(block: (HunMutator) -> R): R { 787 val mutator = HunMutatorImpl(this) 788 return block(mutator).also { mutator.commitModifications() } 789 } 790 791 /** Mutates the HeadsUp state of notifications. */ 792 private interface HunMutator { 793 fun updateNotification(key: String, alert: Boolean) 794 fun removeNotification(key: String, releaseImmediately: Boolean) 795 } 796 797 /** 798 * [HunMutator] implementation that defers removing notifications from the HeadsUpManager until 799 * after additions/updates. 800 */ 801 private class HunMutatorImpl(private val headsUpManager: HeadsUpManager) : HunMutator { 802 private val deferred = mutableListOf<Pair<String, Boolean>>() 803 804 override fun updateNotification(key: String, alert: Boolean) { 805 headsUpManager.updateNotification(key, alert) 806 } 807 808 override fun removeNotification(key: String, releaseImmediately: Boolean) { 809 val args = Pair(key, releaseImmediately) 810 deferred.add(args) 811 } 812 813 fun commitModifications() { 814 deferred.forEach { (key, releaseImmediately) -> 815 headsUpManager.removeNotification(key, releaseImmediately) 816 } 817 deferred.clear() 818 } 819 } 820