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