• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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