1 /*
<lambda>null2 * 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.app.NotificationChannel.SYSTEM_RESERVED_IDS
21 import android.util.ArrayMap
22 import android.util.ArraySet
23 import com.android.internal.annotations.VisibleForTesting
24 import com.android.systemui.Flags.notificationSkipSilentUpdates
25 import com.android.systemui.dagger.qualifiers.Application
26 import com.android.systemui.dagger.qualifiers.Main
27 import com.android.systemui.statusbar.NotificationRemoteInputManager
28 import com.android.systemui.statusbar.chips.notification.domain.interactor.StatusBarNotificationChipsInteractor
29 import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips
30 import com.android.systemui.statusbar.chips.uievents.StatusBarChipsUiEventLogger
31 import com.android.systemui.statusbar.notification.NotifPipelineFlags
32 import com.android.systemui.statusbar.notification.collection.BundleEntry
33 import com.android.systemui.statusbar.notification.collection.GroupEntry
34 import com.android.systemui.statusbar.notification.collection.NotifCollection
35 import com.android.systemui.statusbar.notification.collection.NotifPipeline
36 import com.android.systemui.statusbar.notification.collection.NotificationEntry
37 import com.android.systemui.statusbar.notification.collection.PipelineEntry
38 import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
39 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifComparator
40 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter
41 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner
42 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
43 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender
44 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender.OnEndLifetimeExtensionCallback
45 import com.android.systemui.statusbar.notification.collection.provider.LaunchFullScreenIntentProvider
46 import com.android.systemui.statusbar.notification.collection.render.NodeController
47 import com.android.systemui.statusbar.notification.dagger.IncomingHeader
48 import com.android.systemui.statusbar.notification.headsup.HeadsUpManager
49 import com.android.systemui.statusbar.notification.headsup.OnHeadsUpChangedListener
50 import com.android.systemui.statusbar.notification.headsup.PinnedStatus
51 import com.android.systemui.statusbar.notification.interruption.HeadsUpViewBinder
52 import com.android.systemui.statusbar.notification.interruption.VisualInterruptionDecisionLogger
53 import com.android.systemui.statusbar.notification.interruption.VisualInterruptionDecisionProvider
54 import com.android.systemui.statusbar.notification.interruption.VisualInterruptionDecisionProviderImpl.DecisionImpl
55 import com.android.systemui.statusbar.notification.interruption.VisualInterruptionType
56 import com.android.systemui.statusbar.notification.logKey
57 import com.android.systemui.statusbar.notification.row.NotificationActionClickManager
58 import com.android.systemui.statusbar.notification.shared.GroupHunAnimationFix
59 import com.android.systemui.statusbar.notification.shared.NotificationBundleUi
60 import com.android.systemui.statusbar.notification.stack.BUCKET_HEADS_UP
61 import com.android.systemui.util.concurrency.DelayableExecutor
62 import com.android.systemui.util.time.SystemClock
63 import java.util.function.Consumer
64 import javax.inject.Inject
65 import kotlinx.coroutines.CoroutineScope
66 import kotlinx.coroutines.launch
67
68 /**
69 * Coordinates heads up notification (HUN) interactions with the notification pipeline based on the
70 * HUN state reported by the [HeadsUpManager]. In this class we only consider one notification, in
71 * particular the [HeadsUpManager.getTopEntry], to be HeadsUpping at a time even though other
72 * notifications may be queued to heads up next.
73 *
74 * The current HUN, but not HUNs that are queued to heads up, will be:
75 * - Lifetime extended until it's no longer heads upping.
76 * - Promoted out of its group if it's a child of a group.
77 * - In the HeadsUpCoordinatorSection. Ordering is configured in [NotifCoordinators].
78 * - Removed from HeadsUpManager if it's removed from the NotificationCollection.
79 *
80 * Note: The inflation callback in [PreparationCoordinator] handles showing HUNs.
81 */
82 @CoordinatorScope
83 class HeadsUpCoordinator
84 @Inject
85 constructor(
86 @Application private val applicationScope: CoroutineScope,
87 private val mLogger: HeadsUpCoordinatorLogger,
88 private val mInterruptLogger: VisualInterruptionDecisionLogger,
89 private val mSystemClock: SystemClock,
90 private val notifCollection: NotifCollection,
91 private val mHeadsUpManager: HeadsUpManager,
92 private val mHeadsUpViewBinder: HeadsUpViewBinder,
93 private val mVisualInterruptionDecisionProvider: VisualInterruptionDecisionProvider,
94 private val mRemoteInputManager: NotificationRemoteInputManager,
95 private val notificationActionClickManager: NotificationActionClickManager,
96 private val mLaunchFullScreenIntentProvider: LaunchFullScreenIntentProvider,
97 private val mFlags: NotifPipelineFlags,
98 private val statusBarNotificationChipsInteractor: StatusBarNotificationChipsInteractor,
99 private val statusBarChipsUiEventLogger: StatusBarChipsUiEventLogger,
100 @IncomingHeader private val mIncomingHeaderController: NodeController,
101 @Main private val mExecutor: DelayableExecutor,
102 ) : Coordinator {
103 private val mEntriesBindingUntil = ArrayMap<String, Long>()
104 private val mEntriesUpdateTimes = ArrayMap<String, Long>()
105 private val mFSIUpdateCandidates = ArrayMap<String, Long>()
106 private var mEndLifetimeExtension: OnEndLifetimeExtensionCallback? = null
107 private lateinit var mNotifPipeline: NotifPipeline
108 private var mNow: Long = -1
109 private val mPostedEntries = LinkedHashMap<String, PostedEntry>()
110
111 // notifs we've extended the lifetime for with cancellation callbacks
112 private val mNotifsExtendingLifetime = ArrayMap<NotificationEntry, Runnable?>()
113
114 override fun attach(pipeline: NotifPipeline) {
115 mNotifPipeline = pipeline
116 mHeadsUpManager.addListener(mOnHeadsUpChangedListener)
117 pipeline.addCollectionListener(mNotifCollectionListener)
118 pipeline.addOnBeforeTransformGroupsListener { onBeforeTransformGroups() }
119 pipeline.addOnBeforeFinalizeFilterListener(::onBeforeFinalizeFilter)
120 pipeline.addPromoter(mNotifPromoter)
121 pipeline.addNotificationLifetimeExtender(mLifetimeExtender)
122 if (NotificationBundleUi.isEnabled) {
123 notificationActionClickManager.addActionClickListener(mActionPressListener)
124 } else {
125 mRemoteInputManager.addActionPressListener(mActionPressListener)
126 }
127
128 if (StatusBarNotifChips.isEnabled) {
129 applicationScope.launch {
130 statusBarNotificationChipsInteractor.promotedNotificationChipTapEvent.collect {
131 onPromotedNotificationChipTapEvent(it)
132 }
133 }
134 }
135 }
136
137 /**
138 * Updates the heads-up state based on which promoted notification with the given [key] was
139 * tapped.
140 *
141 * Must be run on the main thread.
142 */
143 private fun onPromotedNotificationChipTapEvent(key: String) {
144 StatusBarNotifChips.unsafeAssertInNewMode()
145
146 val entry = notifCollection.getEntry(key)
147 if (entry == null) {
148 mLogger.logPromotedNotificationForHeadsUpNotFound(key)
149 return
150 }
151 // TODO(b/364653005): Validate that the given key indeed matches a promoted notification,
152 // not just any notification.
153
154 val isCurrentlyHeadsUp = mHeadsUpManager.isHeadsUpEntry(entry.key)
155
156 if (isCurrentlyHeadsUp) {
157 // If the chip's notif is currently showing as heads up, then we'll stop showing it.
158 statusBarChipsUiEventLogger.logChipTapToHide(entry.sbn.instanceId)
159 } else {
160 statusBarChipsUiEventLogger.logChipTapToShow(entry.sbn.instanceId)
161 }
162
163 val posted =
164 PostedEntry(
165 entry,
166 wasAdded = false,
167 wasUpdated = false,
168 // We want the chip to act as a toggle, so if the chip's notification is currently
169 // showing as heads up, then we should stop showing it.
170 shouldHeadsUpEver = !isCurrentlyHeadsUp,
171 shouldHeadsUpAgain = !isCurrentlyHeadsUp,
172 isPinnedByUser = true,
173 isHeadsUpEntry = isCurrentlyHeadsUp,
174 isBinding = isEntryBinding(entry),
175 )
176 if (isCurrentlyHeadsUp) {
177 mLogger.logHidePromotedNotificationHeadsUp(key)
178 } else {
179 mLogger.logShowPromotedNotificationHeadsUp(key)
180 }
181
182 mExecutor.execute {
183 mPostedEntries[entry.key] = posted
184 mNotifPromoter.invalidateList("onPromotedNotificationChipTapEvent: ${entry.logKey}")
185 }
186 }
187
188 private fun onHeadsUpViewBound(entry: NotificationEntry, isPinnedByUser: Boolean) {
189 mHeadsUpManager.showNotification(entry, isPinnedByUser)
190 mEntriesBindingUntil.remove(entry.key)
191 }
192
193 /**
194 * Once the pipeline starts running, we can look through posted entries and quickly process any
195 * that don't have groups, and thus will never gave a group heads up edge case.
196 */
197 fun onBeforeTransformGroups() {
198 mNow = mSystemClock.currentTimeMillis()
199 if (mPostedEntries.isEmpty()) {
200 return
201 }
202 // Process all non-group adds/updates
203 mHeadsUpManager.modifyHuns { hunMutator ->
204 mPostedEntries.values.toList().forEach { posted ->
205 if (!posted.entry.sbn.isGroup) {
206 handlePostedEntry(posted, hunMutator, "non-group")
207 mPostedEntries.remove(posted.key)
208 }
209 }
210 }
211 }
212
213 /**
214 * Once we have a nearly final shade list (not including what's pruned for inflation reasons),
215 * we know that stability and [NotifPromoter]s have been applied, so we can use the location of
216 * notifications in this list to determine what kind of group heads up behavior should happen.
217 */
218 fun onBeforeFinalizeFilter(list: List<PipelineEntry>) =
219 mHeadsUpManager.modifyHuns { hunMutator ->
220 // Nothing to do if there are no other adds/updates
221 if (mPostedEntries.isEmpty()) {
222 return@modifyHuns
223 }
224 // Calculate a bunch of information about the logical group and the locations of group
225 // entries in the nearly-finalized shade list. These may be used in the per-group loop.
226 val postedEntriesByGroup = mPostedEntries.values.groupBy { it.entry.sbn.groupKey }
227 val logicalMembersByGroup =
228 mNotifPipeline.allNotifs
229 .asSequence()
230 .filter { postedEntriesByGroup.contains(it.sbn.groupKey) }
231 .groupBy { it.sbn.groupKey }
232 val groupLocationsByKey: Map<String, GroupLocation> by lazy {
233 getGroupLocationsByKey(list)
234 }
235 mLogger.logEvaluatingGroups(postedEntriesByGroup.size)
236 // For each group, determine which notification(s) for a group should heads up.
237 postedEntriesByGroup.forEach { (groupKey, postedEntries) ->
238 // get and classify the logical members
239 val logicalMembers = logicalMembersByGroup[groupKey] ?: emptyList()
240 val logicalSummary = logicalMembers.find { it.sbn.notification.isGroupSummary }
241
242 // Report the start of this group's evaluation
243 mLogger.logEvaluatingGroup(groupKey, postedEntries.size, logicalMembers.size)
244
245 // If there is no logical summary, then there is no heads up to transfer
246 if (logicalSummary == null) {
247 postedEntries.forEach {
248 handlePostedEntry(it, hunMutator, scenario = "logical-summary-missing")
249 }
250 return@forEach
251 }
252
253 // If summary isn't wanted to be heads up, then there is no heads up to transfer
254 if (!isGoingToShowHunStrict(logicalSummary)) {
255 postedEntries.forEach {
256 handlePostedEntry(it, hunMutator, scenario = "logical-summary-not-heads-up")
257 }
258 return@forEach
259 }
260
261 // The group is heads up! Overall goals:
262 // - Maybe transfer its heads up to a child
263 // - Also let any/all newly heads up children still heads up
264 var childToReceiveParentHeadsUp: NotificationEntry?
265 var targetType = "undefined"
266
267 // If the parent is heads up, always look at the posted notification with the newest
268 // 'when', and if it is isolated with GROUP_ALERT_SUMMARY, then it should receive
269 // the
270 // parent's heads up.
271 childToReceiveParentHeadsUp =
272 findHeadsUpOverride(postedEntries, groupLocationsByKey::getLocation)
273 if (childToReceiveParentHeadsUp != null) {
274 targetType = "headsUpOverride"
275 }
276
277 // If the summary is Detached and we have not picked a receiver of the heads up,
278 // then we
279 // need to look for the best child to heads up in place of the summary.
280 val isSummaryAttached = groupLocationsByKey.contains(logicalSummary.key)
281 if (!isSummaryAttached && childToReceiveParentHeadsUp == null) {
282 childToReceiveParentHeadsUp =
283 findBestTransferChild(logicalMembers, groupLocationsByKey::getLocation)
284 if (childToReceiveParentHeadsUp != null) {
285 targetType = "bestChild"
286 }
287 }
288
289 // If there is no child to receive the parent heads up, then just handle the posted
290 // entries and return.
291 if (childToReceiveParentHeadsUp == null) {
292 postedEntries.forEach {
293 handlePostedEntry(it, hunMutator, scenario = "no-transfer-target")
294 }
295 return@forEach
296 }
297
298 if (isDisqualifiedChild(childToReceiveParentHeadsUp)) {
299 mInterruptLogger.logDecision(
300 VisualInterruptionType.PEEK.name,
301 childToReceiveParentHeadsUp,
302 DecisionImpl(shouldInterrupt = false,
303 logReason = "disqualified-transfer-target"))
304 postedEntries.forEach {
305 it.shouldHeadsUpEver = false
306 it.shouldHeadsUpAgain = false
307 handlePostedEntry(it, hunMutator, scenario = "disqualified-transfer-target")
308 }
309 return@forEach
310 }
311 // At this point we just need to initiate the transfer
312 val summaryUpdate = mPostedEntries[logicalSummary.key]
313
314 // Because we now know for certain that some child is going to heads up for this
315 // summary
316 // (as we have found a child to transfer the heads up to), mark the group as having
317 // interrupted. This will allow us to know in the future that the "should heads up"
318 // state of this group has already been handled, just not via the summary entry
319 // itself.
320 logicalSummary.setInterruption()
321 mLogger.logSummaryMarkedInterrupted(
322 logicalSummary.key,
323 childToReceiveParentHeadsUp.key,
324 )
325
326 // If the summary was not attached, then remove the heads up from the detached
327 // summary.
328 // Otherwise we can simply ignore its posted update.
329 if (!isSummaryAttached) {
330 val summaryUpdateForRemoval =
331 summaryUpdate?.also { it.shouldHeadsUpEver = false }
332 ?: PostedEntry(
333 logicalSummary,
334 wasAdded = false,
335 wasUpdated = false,
336 shouldHeadsUpEver = false,
337 shouldHeadsUpAgain = false,
338 isHeadsUpEntry = mHeadsUpManager.isHeadsUpEntry(logicalSummary.key),
339 isBinding = isEntryBinding(logicalSummary),
340 )
341 // If we transfer the heads up notification and the summary isn't even attached,
342 // that means we should ensure the summary is no longer a heads up notification,
343 // so we remove it here.
344 handlePostedEntry(
345 summaryUpdateForRemoval,
346 hunMutator,
347 scenario = "detached-summary-remove-heads-up",
348 )
349 } else if (summaryUpdate != null) {
350 mLogger.logPostedEntryWillNotEvaluate(
351 summaryUpdate,
352 reason = "attached-summary-transferred",
353 )
354 }
355
356 // Handle all posted entries -- if the child receiving the parent's heads up is in
357 // the
358 // list, then set its flags to ensure it heads up.
359 var didHeadsUpChildToReceiveParentHeadsUp = false
360 postedEntries
361 .asSequence()
362 .filter { it.key != logicalSummary.key }
363 .forEach { postedEntry ->
364 if (childToReceiveParentHeadsUp.key == postedEntry.key) {
365 // Update the child's posted update so that it
366 postedEntry.shouldHeadsUpEver = true
367 postedEntry.shouldHeadsUpAgain = true
368 handlePostedEntry(
369 postedEntry,
370 hunMutator,
371 scenario = "child-heads-up-transfer-target-$targetType",
372 )
373 didHeadsUpChildToReceiveParentHeadsUp = true
374 } else {
375 handlePostedEntry(
376 postedEntry,
377 hunMutator,
378 scenario = "child-heads-up-non-target",
379 )
380 }
381 }
382
383 // If the child receiving the heads up notification was not updated on this tick
384 // (which can happen in a standard heads up transfer scenario), then construct an
385 // update
386 // so that we can apply it.
387 if (!didHeadsUpChildToReceiveParentHeadsUp) {
388 val posted =
389 PostedEntry(
390 childToReceiveParentHeadsUp,
391 wasAdded = false,
392 wasUpdated = false,
393 shouldHeadsUpEver = true,
394 shouldHeadsUpAgain = true,
395 isHeadsUpEntry =
396 mHeadsUpManager.isHeadsUpEntry(childToReceiveParentHeadsUp.key),
397 isBinding = isEntryBinding(childToReceiveParentHeadsUp),
398 )
399 handlePostedEntry(
400 posted,
401 hunMutator,
402 scenario = "non-posted-child-heads-up-transfer-target-$targetType",
403 )
404 }
405 }
406 // After this method runs, all posted entries should have been handled (or skipped).
407 mPostedEntries.clear()
408
409 // Also take this opportunity to clean up any stale entry update times
410 cleanUpEntryTimes()
411 }
412
413 private fun isDisqualifiedChild(entry: NotificationEntry): Boolean {
414 if (entry.channel == null || entry.channel.id == null) {
415 return false
416 }
417 return entry.channel.id in SYSTEM_RESERVED_IDS
418 }
419
420
421 /**
422 * Find the posted child with the newest when, and return it if it is isolated and has
423 * GROUP_ALERT_SUMMARY so that it can be heads uped.
424 */
425 private fun findHeadsUpOverride(
426 postedEntries: List<PostedEntry>,
427 locationLookupByKey: (String) -> GroupLocation,
428 ): NotificationEntry? =
429 postedEntries
430 .asSequence()
431 .filter { posted -> !posted.entry.sbn.notification.isGroupSummary }
432 .sortedBy { posted -> -posted.entry.sbn.notification.getWhen() }
433 .firstOrNull()
434 ?.let { posted ->
435 posted.entry.takeIf { entry ->
436 locationLookupByKey(entry.key) == GroupLocation.Isolated &&
437 entry.sbn.notification.groupAlertBehavior == GROUP_ALERT_SUMMARY
438 }
439 }
440
441 /**
442 * Of children which are attached, look for the child to receive the notification: First prefer
443 * children which were updated, then looking for the ones with the newest 'when'
444 */
445 private fun findBestTransferChild(
446 logicalMembers: List<NotificationEntry>,
447 locationLookupByKey: (String) -> GroupLocation,
448 ): NotificationEntry? =
449 logicalMembers
450 .asSequence()
451 .filter { !it.sbn.notification.isGroupSummary }
452 .filter { locationLookupByKey(it.key) != GroupLocation.Detached }
453 .sortedWith(
454 compareBy({ !mPostedEntries.contains(it.key) }, { -it.sbn.notification.getWhen() })
455 )
456 .firstOrNull()
457
458 private fun getGroupLocationsByKey(list: List<PipelineEntry>): Map<String, GroupLocation> =
459 mutableMapOf<String, GroupLocation>().also { map ->
460 list.forEach { topLevelEntry ->
461 when (topLevelEntry) {
462 is NotificationEntry -> map[topLevelEntry.key] = GroupLocation.Isolated
463 is GroupEntry -> {
464 topLevelEntry.summary?.let { summary ->
465 map[summary.key] = GroupLocation.Summary
466 }
467 topLevelEntry.children.forEach { child ->
468 map[child.key] = GroupLocation.Child
469 }
470 }
471 is BundleEntry -> map[topLevelEntry.key] = GroupLocation.Bundle
472 else -> error("unhandled type $topLevelEntry")
473 }
474 }
475 }
476
477 private fun handlePostedEntry(posted: PostedEntry, hunMutator: HunMutator, scenario: String) {
478 mLogger.logPostedEntryWillEvaluate(posted, scenario)
479
480 if (posted.wasAdded) {
481 if (posted.shouldHeadsUpEver) {
482 bindForAsyncHeadsUp(posted)
483 }
484 } else {
485 if (posted.isHeadsUpAlready) {
486 // NOTE: This might be because we're showing heads up (i.e. tracked by
487 // HeadsUpManager) OR it could be because we're binding, and that will affect the
488 // next step.
489 if (posted.shouldHeadsUpEver) {
490 // If showing heads up, we need to post an update. Otherwise we're still
491 // binding, and we can just let that finish.
492 if (posted.isHeadsUpEntry) {
493 val pinnedStatus =
494 if (posted.shouldHeadsUpAgain) {
495 if (StatusBarNotifChips.isEnabled && posted.isPinnedByUser) {
496 PinnedStatus.PinnedByUser
497 } else {
498 PinnedStatus.PinnedBySystem
499 }
500 } else {
501 PinnedStatus.NotPinned
502 }
503 hunMutator.updateNotification(posted.key, pinnedStatus)
504 }
505 } else { // shouldHeadsUpEver = false
506 if (posted.isHeadsUpEntry) {
507 if (notificationSkipSilentUpdates()) {
508 if (posted.isPinnedByUser) {
509 // We don't want this to be interrupting anymore, let's remove it
510 // If the notification is pinned by the user, the only way a user
511 // can un-pin it by tapping the status bar notification chip. Since
512 // that's a clear user action, we should remove the HUN immediately
513 // instead of waiting for any sort of minimum timeout.
514 // TODO(b/401068530) Ensure that status bar chip HUNs are not
515 // removed for silent update
516 hunMutator.removeNotification(
517 posted.key,
518 /* releaseImmediately= */ true,
519 )
520 } else {
521 // Do NOT remove HUN for non-user update.
522 // Let the HUN show for its remaining duration.
523 }
524 } else {
525 // We don't want this to be interrupting anymore, let's remove it
526 // If the notification is pinned by the user, the only way a user can
527 // un-pin it is by tapping the status bar notification chip. Since
528 // that's a clear user action, we should remove the HUN immediately
529 // instead of waiting for any sort of minimum timeout.
530 val shouldRemoveImmediately = posted.isPinnedByUser
531 hunMutator.removeNotification(posted.key, shouldRemoveImmediately)
532 }
533 } else {
534 // Don't let the bind finish
535 cancelHeadsUpBind(posted.entry)
536 }
537 }
538 } else if (posted.shouldHeadsUpEver && posted.shouldHeadsUpAgain) {
539 // This notification was updated to be heads up, show it!
540 bindForAsyncHeadsUp(posted)
541 }
542 }
543 }
544
545 private fun cancelHeadsUpBind(entry: NotificationEntry) {
546 mEntriesBindingUntil.remove(entry.key)
547 mHeadsUpViewBinder.abortBindCallback(entry)
548 }
549
550 private fun bindForAsyncHeadsUp(posted: PostedEntry) {
551 val isPinnedByUser = StatusBarNotifChips.isEnabled && posted.isPinnedByUser
552 // TODO: Add a guarantee to bindHeadsUpView of some kind of callback if the bind is
553 // cancelled so that we don't need to have this sad timeout hack.
554 mEntriesBindingUntil[posted.key] = mNow + BIND_TIMEOUT
555 mHeadsUpViewBinder.bindHeadsUpView(posted.entry, isPinnedByUser, this::onHeadsUpViewBound)
556 }
557
558 private val mNotifCollectionListener =
559 object : NotifCollectionListener {
560 /**
561 * Notification was just added and if it should heads up, bind the view and then show
562 * it.
563 */
564 override fun onEntryAdded(entry: NotificationEntry) {
565 // First check whether this notification should launch a full screen intent, and
566 // launch it if needed.
567 val fsiDecision =
568 mVisualInterruptionDecisionProvider.makeUnloggedFullScreenIntentDecision(entry)
569 mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(fsiDecision)
570 if (fsiDecision.shouldInterrupt) {
571 mLaunchFullScreenIntentProvider.launchFullScreenIntent(entry)
572 } else if (fsiDecision.wouldInterruptWithoutDnd) {
573 // If DND was the only reason this entry was suppressed, note it for potential
574 // reconsideration on later ranking updates.
575 addForFSIReconsideration(entry, mSystemClock.currentTimeMillis())
576 }
577
578 // makeAndLogHeadsUpDecision includes check for whether this notification should be
579 // filtered
580 val shouldHeadsUpEver =
581 mVisualInterruptionDecisionProvider
582 .makeAndLogHeadsUpDecision(entry)
583 .shouldInterrupt
584 mPostedEntries[entry.key] =
585 PostedEntry(
586 entry,
587 wasAdded = true,
588 wasUpdated = false,
589 shouldHeadsUpEver = shouldHeadsUpEver,
590 shouldHeadsUpAgain = true,
591 isHeadsUpEntry = false,
592 isBinding = false,
593 )
594
595 // Record the last updated time for this key
596 setUpdateTime(entry, mSystemClock.currentTimeMillis())
597 }
598
599 /**
600 * Notification could've updated to be heads up or not heads up. Even if it did update
601 * to heads up, if the notification specified that it only wants to heads up once, don't
602 * heads up again.
603 */
604 override fun onEntryUpdated(entry: NotificationEntry) {
605 val shouldHeadsUpEver =
606 mVisualInterruptionDecisionProvider
607 .makeAndLogHeadsUpDecision(entry)
608 .shouldInterrupt
609 val shouldHeadsUpAgain = shouldHunAgain(entry)
610 val isHeadsUpEntry = mHeadsUpManager.isHeadsUpEntry(entry.key)
611 val isBinding = isEntryBinding(entry)
612 val posted =
613 mPostedEntries.compute(entry.key) { _, value ->
614 value?.also { update ->
615 update.wasUpdated = true
616 update.shouldHeadsUpEver = shouldHeadsUpEver
617 update.shouldHeadsUpAgain =
618 update.shouldHeadsUpAgain || shouldHeadsUpAgain
619 update.isHeadsUpEntry = isHeadsUpEntry
620 update.isBinding = isBinding
621 }
622 ?: PostedEntry(
623 entry,
624 wasAdded = false,
625 wasUpdated = true,
626 shouldHeadsUpEver = shouldHeadsUpEver,
627 shouldHeadsUpAgain = shouldHeadsUpAgain,
628 isHeadsUpEntry = isHeadsUpEntry,
629 isBinding = isBinding,
630 )
631 }
632 if (notificationSkipSilentUpdates()) {
633 // TODO(b/403703828) Move canceling to OnBeforeFinalizeFilter, since we are not
634 // removing from HeadsUpManager and don't need to deal with re-entrant behavior
635 // between HeadsUpCoordinator, HeadsUpManager, and VisualStabilityManager.
636 if (
637 posted?.shouldHeadsUpEver == false &&
638 !posted.isHeadsUpEntry &&
639 posted.isBinding
640 ) {
641 // Don't let the bind finish
642 cancelHeadsUpBind(posted.entry)
643 }
644 } else {
645 // Handle cancelling heads up here, rather than in the OnBeforeFinalizeFilter,
646 // so that work can be done before the ShadeListBuilder is run. This prevents
647 // re-entrant behavior between this Coordinator, HeadsUpManager, and
648 // VisualStabilityManager.
649 if (posted?.shouldHeadsUpEver == false) {
650 if (posted.isHeadsUpEntry) {
651 // We don't want this to be interrupting anymore, let's remove it
652 mHeadsUpManager.removeNotification(
653 posted.key,
654 /* removeImmediately= */ false,
655 "onEntryUpdated",
656 )
657 } else if (posted.isBinding) {
658 // Don't let the bind finish
659 cancelHeadsUpBind(posted.entry)
660 }
661 }
662 }
663 // Update last updated time for this entry
664 setUpdateTime(entry, mSystemClock.currentTimeMillis())
665 }
666
667 /** Stop showing as heads up once removed from the notification collection */
668 override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
669 mPostedEntries.remove(entry.key)
670 mEntriesUpdateTimes.remove(entry.key)
671 cancelHeadsUpBind(entry)
672 val entryKey = entry.key
673 if (mHeadsUpManager.isHeadsUpEntry(entryKey)) {
674 // TODO: This should probably know the RemoteInputCoordinator's conditions,
675 // or otherwise reference that coordinator's state, rather than replicate its
676 // logic
677 val removeImmediatelyForRemoteInput =
678 (mRemoteInputManager.isSpinning(entryKey) &&
679 !NotificationRemoteInputManager.FORCE_REMOTE_INPUT_HISTORY)
680 mHeadsUpManager.removeNotification(
681 entry.key,
682 removeImmediatelyForRemoteInput,
683 "onEntryRemoved, reason: $reason",
684 )
685 }
686 }
687
688 override fun onEntryCleanUp(entry: NotificationEntry) {
689 mHeadsUpViewBinder.abortBindCallback(entry)
690 }
691
692 /**
693 * Identify notifications whose heads-up state changes when the notification rankings
694 * are updated, and have those changed notifications heads up if necessary.
695 *
696 * This method will occur after any operations in onEntryAdded or onEntryUpdated, so any
697 * handling of ranking changes needs to take into account that we may have just made a
698 * PostedEntry for some of these notifications.
699 */
700 override fun onRankingApplied() {
701 // Because a ranking update may cause some notifications that are no longer (or were
702 // never) in mPostedEntries to need to heads up, we need to check every notification
703 // known to the pipeline.
704 for (entry in mNotifPipeline.allNotifs) {
705 // Only consider entries that are recent enough, since we want to apply a fairly
706 // strict threshold for when an entry should be updated via only ranking and not
707 // an
708 // app-provided notification update.
709 if (!isNewEnoughForRankingUpdate(entry)) continue
710
711 // The only entries we consider heads up for here are entries that have never
712 // interrupted and that now say they should heads up or FSI; if they've heads
713 // uped in
714 // the past, we don't want to incorrectly heads up a second time if there wasn't
715 // an
716 // explicit notification update.
717 if (entry.hasInterrupted()) continue
718
719 // Before potentially allowing heads-up, check for any candidates for a FSI
720 // launch.
721 // Any entry that is a candidate meets two criteria:
722 // - was suppressed from FSI launch only by a DND suppression
723 // - is within the recency window for reconsideration
724 // If any of these entries are no longer suppressed, launch the FSI now.
725 if (isCandidateForFSIReconsideration(entry)) {
726 val decision =
727 mVisualInterruptionDecisionProvider
728 .makeUnloggedFullScreenIntentDecision(entry)
729 if (decision.shouldInterrupt) {
730 // Log both the launch of the full screen and also that this was via a
731 // ranking update, and finally revoke candidacy for FSI reconsideration
732 mLogger.logEntryUpdatedToFullScreen(entry.key, decision.logReason)
733 mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(
734 decision
735 )
736 mLaunchFullScreenIntentProvider.launchFullScreenIntent(entry)
737 mFSIUpdateCandidates.remove(entry.key)
738
739 // if we launch the FSI then this is no longer a candidate for HUN
740 continue
741 } else if (decision.wouldInterruptWithoutDnd) {
742 // decision has not changed; no need to log
743 } else {
744 // some other condition is now blocking FSI; log that and revoke
745 // candidacy
746 // for FSI reconsideration
747 mLogger.logEntryDisqualifiedFromFullScreen(
748 entry.key,
749 decision.logReason,
750 )
751 mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(
752 decision
753 )
754 mFSIUpdateCandidates.remove(entry.key)
755 }
756 }
757
758 // The cases where we should consider this notification to be updated:
759 // - if this entry is not present in PostedEntries, and is now in a
760 // shouldHeadsUp
761 // state
762 // - if it is present in PostedEntries and the previous state of shouldHeadsUp
763 // differs from the updated one
764 val decision =
765 mVisualInterruptionDecisionProvider.makeUnloggedHeadsUpDecision(entry)
766 val shouldHeadsUpEver = decision.shouldInterrupt
767 val postedShouldHeadsUpEver =
768 mPostedEntries[entry.key]?.shouldHeadsUpEver ?: false
769 val shouldUpdateEntry = postedShouldHeadsUpEver != shouldHeadsUpEver
770
771 if (shouldUpdateEntry) {
772 mLogger.logEntryUpdatedByRanking(
773 entry.key,
774 shouldHeadsUpEver,
775 decision.logReason,
776 )
777 onEntryUpdated(entry)
778 }
779 }
780 }
781 }
782
783 /** Checks whether an update for a notification warrants an heads up for the user. */
784 private fun shouldHunAgain(entry: NotificationEntry): Boolean {
785 return (!entry.hasInterrupted() ||
786 (entry.sbn.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE) == 0)
787 }
788
789 /** Sets the updated time for the given entry to the specified time. */
790 @VisibleForTesting
791 fun setUpdateTime(entry: NotificationEntry, time: Long) {
792 mEntriesUpdateTimes[entry.key] = time
793 }
794
795 /**
796 * Add the entry to the list of entries potentially considerable for FSI ranking update, where
797 * the provided time is the time the entry was added.
798 */
799 @VisibleForTesting
800 fun addForFSIReconsideration(entry: NotificationEntry, time: Long) {
801 mFSIUpdateCandidates[entry.key] = time
802 }
803
804 /**
805 * Checks whether the entry is new enough to be updated via ranking update. We want to avoid
806 * updating an entry too long after it was originally posted/updated when we're only reacting to
807 * a ranking change, as relevant ranking updates are expected to come in fairly soon after the
808 * posting of a notification.
809 */
810 private fun isNewEnoughForRankingUpdate(entry: NotificationEntry): Boolean {
811 // If we don't have an update time for this key, default to "too old"
812 if (!mEntriesUpdateTimes.containsKey(entry.key)) return false
813
814 val updateTime = mEntriesUpdateTimes[entry.key] ?: return false
815 return (mSystemClock.currentTimeMillis() - updateTime) <= MAX_RANKING_UPDATE_DELAY_MS
816 }
817
818 /**
819 * Checks whether the entry is present new enough for reconsideration for full screen launch.
820 * The time window is the same as for ranking update, but this doesn't allow a potential update
821 * to an entry with full screen intent to count for timing purposes.
822 */
823 private fun isCandidateForFSIReconsideration(entry: NotificationEntry): Boolean {
824 val addedTime = mFSIUpdateCandidates[entry.key] ?: return false
825 return (mSystemClock.currentTimeMillis() - addedTime) <= MAX_RANKING_UPDATE_DELAY_MS
826 }
827
828 private fun cleanUpEntryTimes() {
829 // Because we won't update entries that are older than this amount of time anyway, clean
830 // up any entries that are too old to notify from both the general and FSI specific lists.
831
832 // Anything newer than this time is still within the window.
833 val timeThreshold = mSystemClock.currentTimeMillis() - MAX_RANKING_UPDATE_DELAY_MS
834
835 val toRemove = ArraySet<String>()
836 for ((key, updateTime) in mEntriesUpdateTimes) {
837 if (updateTime == null || timeThreshold > updateTime) {
838 toRemove.add(key)
839 }
840 }
841 mEntriesUpdateTimes.removeAll(toRemove)
842
843 val toRemoveForFSI = ArraySet<String>()
844 for ((key, addedTime) in mFSIUpdateCandidates) {
845 if (addedTime == null || timeThreshold > addedTime) {
846 toRemoveForFSI.add(key)
847 }
848 }
849 mFSIUpdateCandidates.removeAll(toRemoveForFSI)
850 }
851
852 /**
853 * When an action is pressed on a notification, make sure we don't lifetime-extend it in the
854 * future by informing the HeadsUpManager, and make sure we don't keep lifetime-extending it if
855 * we already are.
856 *
857 * @see HeadsUpManager.setUserActionMayIndirectlyRemove
858 * @see HeadsUpManager.canRemoveImmediately
859 */
860 private val mActionPressListener =
861 Consumer<NotificationEntry> { entry ->
862 mHeadsUpManager.setUserActionMayIndirectlyRemove(entry.key)
863 mExecutor.execute { endNotifLifetimeExtensionIfExtended(entry) }
864 }
865
866 private val mLifetimeExtender =
867 object : NotifLifetimeExtender {
868 override fun getName() = TAG
869
870 override fun setCallback(callback: OnEndLifetimeExtensionCallback) {
871 mEndLifetimeExtension = callback
872 }
873
874 override fun maybeExtendLifetime(entry: NotificationEntry, reason: Int): Boolean {
875 if (mHeadsUpManager.canRemoveImmediately(entry.key)) {
876 return false
877 }
878 if (isSticky(entry)) {
879 val removeAfterMillis = mHeadsUpManager.getEarliestRemovalTime(entry.key)
880 mNotifsExtendingLifetime[entry] =
881 mExecutor.executeDelayed(
882 {
883 mHeadsUpManager.removeNotification(
884 entry.key, /* releaseImmediately */
885 true,
886 "cancel lifetime extension - extended for reason: " +
887 "$reason, isSticky: true",
888 )
889 },
890 removeAfterMillis,
891 )
892 } else {
893 mExecutor.execute {
894 mHeadsUpManager.removeNotification(
895 entry.key, /* releaseImmediately */
896 false,
897 "lifetime extension - extended for reason: $reason" +
898 ", isSticky: false",
899 )
900 }
901 mNotifsExtendingLifetime[entry] = null
902 }
903 return true
904 }
905
906 override fun cancelLifetimeExtension(entry: NotificationEntry) {
907 mNotifsExtendingLifetime.remove(entry)?.run()
908 }
909 }
910
911 private val mNotifPromoter =
912 object : NotifPromoter(TAG) {
913 override fun shouldPromoteToTopLevel(entry: NotificationEntry): Boolean =
914 isGoingToShowHunNoRetract(entry)
915 }
916
917 val sectioner =
918 object : NotifSectioner("HeadsUp", BUCKET_HEADS_UP) {
919 override fun isInSection(entry: PipelineEntry): Boolean {
920 if (BundleUtil.isClassified(entry)) {
921 return false
922 }
923 // TODO: This check won't notice if a child of the group is going to HUN...
924 return isGoingToShowHunNoRetract(entry)
925 }
926
927 override fun getComparator(): NotifComparator {
928 return object : NotifComparator("HeadsUp") {
929 override fun compare(o1: PipelineEntry, o2: PipelineEntry): Int =
930 mHeadsUpManager.compare(o1.representativeEntry, o2.representativeEntry)
931 }
932 }
933
934 override fun getHeaderNodeController(): NodeController? =
935 // TODO: remove SHOW_ALL_SECTIONS, this redundant method, and
936 // mIncomingHeaderController
937 if (RankingCoordinator.SHOW_ALL_SECTIONS) mIncomingHeaderController else null
938 }
939
940 private val mOnHeadsUpChangedListener =
941 object : OnHeadsUpChangedListener {
942 override fun onHeadsUpStateChanged(entry: NotificationEntry, isHeadsUp: Boolean) {
943 if (!isHeadsUp) {
944 mNotifPromoter.invalidateList("headsUpEnded: ${entry.logKey}")
945 mHeadsUpViewBinder.unbindHeadsUpView(entry)
946 endNotifLifetimeExtensionIfExtended(entry)
947 }
948 }
949
950 override fun onHeadsUpAnimatingAwayEnded(entry: NotificationEntry) {
951 mNotifPromoter.invalidateList("headsUpAnimatingAwayEnded: ${entry.logKey}")
952 }
953 }
954
955 private fun isSticky(entry: NotificationEntry) = mHeadsUpManager.isSticky(entry.key)
956
957 private fun isEntryBinding(entry: PipelineEntry): Boolean {
958 val bindingUntil = mEntriesBindingUntil[entry.key]
959 return bindingUntil != null && bindingUntil >= mNow
960 }
961
962 /**
963 * Whether the notification is already heads up or binding so that it can imminently heads up
964 */
965 private fun isAttemptingToShowHun(entry: PipelineEntry) =
966 mHeadsUpManager.isHeadsUpEntry(entry.key) ||
967 isEntryBinding(entry) ||
968 isHeadsUpAnimatingAway(entry)
969
970 private fun isHeadsUpAnimatingAway(entry: PipelineEntry): Boolean {
971 if (!GroupHunAnimationFix.isEnabled) return false
972 return entry.representativeEntry?.row?.isHeadsUpAnimatingAway ?: false
973 }
974
975 /**
976 * Whether the notification is already heads up/binding per [isAttemptingToShowHun] OR if it has
977 * been updated so that it should heads up this update. This method is permissive because it
978 * returns `true` even if the update would (in isolation of its group) cause the heads up to be
979 * retracted. This is important for not retracting transferred group heads ups.
980 */
981 private fun isGoingToShowHunNoRetract(entry: PipelineEntry) =
982 mPostedEntries[entry.key]?.calculateShouldBeHeadsUpNoRetract ?: isAttemptingToShowHun(entry)
983
984 /**
985 * If the notification has been updated, then whether it should HUN in isolation, otherwise
986 * defers to the already heads up/binding state of [isAttemptingToShowHun]. This method is
987 * strict because any update which would revoke the heads up supersedes the current heads
988 * up/binding state.
989 */
990 private fun isGoingToShowHunStrict(entry: PipelineEntry) =
991 mPostedEntries[entry.key]?.calculateShouldBeHeadsUpStrict ?: isAttemptingToShowHun(entry)
992
993 private fun endNotifLifetimeExtensionIfExtended(entry: NotificationEntry) {
994 if (mNotifsExtendingLifetime.contains(entry)) {
995 mNotifsExtendingLifetime.remove(entry)?.run()
996 mEndLifetimeExtension?.onEndLifetimeExtension(mLifetimeExtender, entry)
997 }
998 }
999
1000 companion object {
1001 private const val TAG = "HeadsUpCoordinator"
1002 private const val BIND_TIMEOUT = 1000L
1003
1004 // This value is set to match MAX_SOUND_DELAY_MS in NotificationRecord.
1005 private const val MAX_RANKING_UPDATE_DELAY_MS: Long = 2000
1006 }
1007
1008 data class PostedEntry(
1009 val entry: NotificationEntry,
1010 val wasAdded: Boolean,
1011 var wasUpdated: Boolean,
1012 var shouldHeadsUpEver: Boolean,
1013 var shouldHeadsUpAgain: Boolean,
1014 var isPinnedByUser: Boolean = false,
1015 var isHeadsUpEntry: Boolean,
1016 var isBinding: Boolean,
1017 ) {
1018 val key = entry.key
1019 val isHeadsUpAlready: Boolean
1020 get() = isHeadsUpEntry || isBinding
1021
1022 val calculateShouldBeHeadsUpStrict: Boolean
1023 get() = shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain || isHeadsUpAlready)
1024
1025 val calculateShouldBeHeadsUpNoRetract: Boolean
1026 get() = isHeadsUpAlready || (shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain))
1027 }
1028 }
1029
1030 private enum class GroupLocation {
1031 Detached,
1032 Isolated,
1033 Summary,
1034 Child,
1035 Bundle,
1036 }
1037
getLocationnull1038 private fun Map<String, GroupLocation>.getLocation(key: String): GroupLocation =
1039 getOrDefault(key, GroupLocation.Detached)
1040
1041 /**
1042 * Invokes the given block with a [HunMutator] that defers all HUN removals. This ensures that the
1043 * HeadsUpManager is notified of additions before removals, which prevents a glitch where the
1044 * HeadsUpManager temporarily believes that nothing is heads up, causing bad re-entrant behavior.
1045 */
1046 private fun <R> HeadsUpManager.modifyHuns(block: (HunMutator) -> R): R {
1047 val mutator = HunMutatorImpl(this)
1048 return block(mutator).also { mutator.commitModifications() }
1049 }
1050
1051 /** Mutates the HeadsUp state of notifications. */
1052 private interface HunMutator {
updateNotificationnull1053 fun updateNotification(key: String, requestedPinnedStatus: PinnedStatus)
1054
1055 fun removeNotification(key: String, releaseImmediately: Boolean)
1056 }
1057
1058 /**
1059 * [HunMutator] implementation that defers removing notifications from the HeadsUpManager until
1060 * after additions/updates.
1061 */
1062 private class HunMutatorImpl(private val headsUpManager: HeadsUpManager) : HunMutator {
1063 private val deferred = mutableListOf<Pair<String, Boolean>>()
1064
1065 override fun updateNotification(key: String, requestedPinnedStatus: PinnedStatus) {
1066 headsUpManager.updateNotification(key, requestedPinnedStatus)
1067 }
1068
1069 override fun removeNotification(key: String, releaseImmediately: Boolean) {
1070 val args = Pair(key, releaseImmediately)
1071 deferred.add(args)
1072 }
1073
1074 fun commitModifications() {
1075 deferred.forEach { (key, releaseImmediately) ->
1076 headsUpManager.removeNotification(key, releaseImmediately, "commitModifications")
1077 }
1078 deferred.clear()
1079 }
1080 }
1081