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.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 heads up 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 heads up 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 heads up.
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 heads up 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 heads up to transfer
161             if (!isGoingToShowHunStrict(logicalSummary)) {
162                 postedEntries.forEach {
163                     handlePostedEntry(it, hunMutator, scenario = "logical-summary-not-heads-up")
164                 }
165                 return@forEach
166             }
167 
168             // The group is heads up! Overall goals:
169             //  - Maybe transfer its heads up to a child
170             //  - Also let any/all newly heads up children still heads up
171             var childToReceiveParentHeadsUp: NotificationEntry?
172             var targetType = "undefined"
173 
174             // If the parent is heads up, 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 heads up.
177             childToReceiveParentHeadsUp =
178                 findHeadsUpOverride(postedEntries, groupLocationsByKey::getLocation)
179             if (childToReceiveParentHeadsUp != null) {
180                 targetType = "headsUpOverride"
181             }
182 
183             // If the summary is Detached and we have not picked a receiver of the heads up, then we
184             // need to look for the best child to heads up in place of the summary.
185             val isSummaryAttached = groupLocationsByKey.contains(logicalSummary.key)
186             if (!isSummaryAttached && childToReceiveParentHeadsUp == null) {
187                 childToReceiveParentHeadsUp =
188                     findBestTransferChild(logicalMembers, groupLocationsByKey::getLocation)
189                 if (childToReceiveParentHeadsUp != null) {
190                     targetType = "bestChild"
191                 }
192             }
193 
194             // If there is no child to receive the parent heads up, then just handle the posted
195             // entries and return.
196             if (childToReceiveParentHeadsUp == 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 heads up for this summary
207             // (as we have found a child to transfer the heads up 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, childToReceiveParentHeadsUp.key)
212 
213             // If the summary was not attached, then remove the heads up 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                         isHeadsUpEntry = mHeadsUpManager.isHeadsUpEntry(logicalSummary.key),
225                         isBinding = isEntryBinding(logicalSummary),
226                 )
227                 // If we transfer the heads up notification and the summary isn't even attached,
228                 // that means we should ensure the summary is no longer a heads up notification,
229                 // so we remove it here.
230                 handlePostedEntry(
231                         summaryUpdateForRemoval,
232                         hunMutator,
233                         scenario = "detached-summary-remove-heads-up")
234             } else if (summaryUpdate != null) {
235                 mLogger.logPostedEntryWillNotEvaluate(
236                         summaryUpdate,
237                         reason = "attached-summary-transferred")
238             }
239 
240             // Handle all posted entries -- if the child receiving the parent's heads up is in the
241             // list, then set its flags to ensure it heads up.
242             var didHeadsUpChildToReceiveParentHeadsUp = false
243             postedEntries.asSequence()
244                     .filter { it.key != logicalSummary.key }
245                     .forEach { postedEntry ->
246                         if (childToReceiveParentHeadsUp.key == postedEntry.key) {
247                             // Update the child's posted update so that it
248                             postedEntry.shouldHeadsUpEver = true
249                             postedEntry.shouldHeadsUpAgain = true
250                             handlePostedEntry(
251                                     postedEntry,
252                                     hunMutator,
253                                     scenario = "child-heads-up-transfer-target-$targetType")
254                             didHeadsUpChildToReceiveParentHeadsUp = true
255                         } else {
256                             handlePostedEntry(
257                                     postedEntry,
258                                     hunMutator,
259                                     scenario = "child-heads-up-non-target")
260                         }
261                     }
262 
263             // If the child receiving the heads up notification was not updated on this tick
264             // (which can happen in a standard heads up transfer scenario), then construct an update
265             // so that we can apply it.
266             if (!didHeadsUpChildToReceiveParentHeadsUp) {
267                 val posted = PostedEntry(
268                         childToReceiveParentHeadsUp,
269                         wasAdded = false,
270                         wasUpdated = false,
271                         shouldHeadsUpEver = true,
272                         shouldHeadsUpAgain = true,
273                         isHeadsUpEntry =
274                                 mHeadsUpManager.isHeadsUpEntry(childToReceiveParentHeadsUp.key),
275                         isBinding = isEntryBinding(childToReceiveParentHeadsUp),
276                 )
277                 handlePostedEntry(
278                         posted,
279                         hunMutator,
280                         scenario = "non-posted-child-heads-up-transfer-target-$targetType")
281             }
282         }
283         // After this method runs, all posted entries should have been handled (or skipped).
284         mPostedEntries.clear()
285 
286         // Also take this opportunity to clean up any stale entry update times
287         cleanUpEntryTimes()
288     }
289 
290     /**
291      * Find the posted child with the newest when, and return it if it is isolated and has
292      * GROUP_ALERT_SUMMARY so that it can be heads uped.
293      */
294     private fun findHeadsUpOverride(
295         postedEntries: List<PostedEntry>,
296         locationLookupByKey: (String) -> GroupLocation,
297     ): NotificationEntry? = postedEntries.asSequence()
298         .filter { posted -> !posted.entry.sbn.notification.isGroupSummary }
299         .sortedBy { posted ->
300             -posted.entry.sbn.notification.getWhen()
301         }
302         .firstOrNull()
303         ?.let { posted ->
304             posted.entry.takeIf { entry ->
305                 locationLookupByKey(entry.key) == GroupLocation.Isolated &&
306                         entry.sbn.notification.groupAlertBehavior == GROUP_ALERT_SUMMARY
307             }
308         }
309 
310     /**
311      * Of children which are attached, look for the child to receive the notification:
312      * First prefer children which were updated, then looking for the ones with the newest 'when'
313      */
314     private fun findBestTransferChild(
315         logicalMembers: List<NotificationEntry>,
316         locationLookupByKey: (String) -> GroupLocation,
317     ): NotificationEntry? = logicalMembers.asSequence()
318         .filter { !it.sbn.notification.isGroupSummary }
319         .filter { locationLookupByKey(it.key) != GroupLocation.Detached }
320         .sortedWith(compareBy(
321             { !mPostedEntries.contains(it.key) },
322             { -it.sbn.notification.getWhen() },
323         ))
324         .firstOrNull()
325 
326     private fun getGroupLocationsByKey(list: List<ListEntry>): Map<String, GroupLocation> =
327         mutableMapOf<String, GroupLocation>().also { map ->
328             list.forEach { topLevelEntry ->
329                 when (topLevelEntry) {
330                     is NotificationEntry -> map[topLevelEntry.key] = GroupLocation.Isolated
331                     is GroupEntry -> {
332                         topLevelEntry.summary?.let { summary ->
333                             map[summary.key] = GroupLocation.Summary
334                         }
335                         topLevelEntry.children.forEach { child ->
336                             map[child.key] = GroupLocation.Child
337                         }
338                     }
339                     else -> error("unhandled type $topLevelEntry")
340                 }
341             }
342         }
343 
344     private fun handlePostedEntry(posted: PostedEntry, hunMutator: HunMutator, scenario: String) {
345         mLogger.logPostedEntryWillEvaluate(posted, scenario)
346         if (posted.wasAdded) {
347             if (posted.shouldHeadsUpEver) {
348                 bindForAsyncHeadsUp(posted)
349             }
350         } else {
351             if (posted.isHeadsUpAlready) {
352                 // NOTE: This might be because we're showing heads up (i.e. tracked by
353                 // HeadsUpManager) OR it could be because we're binding, and that will affect the
354                 // next step.
355                 if (posted.shouldHeadsUpEver) {
356                     // If showing heads up, we need to post an update. Otherwise we're still
357                     // binding, and we can just let that finish.
358                     if (posted.isHeadsUpEntry) {
359                         hunMutator.updateNotification(posted.key, posted.shouldHeadsUpAgain)
360                     }
361                 } else {
362                     if (posted.isHeadsUpEntry) {
363                         // We don't want this to be interrupting anymore, let's remove it
364                         hunMutator.removeNotification(posted.key, false /*removeImmediately*/)
365                     } else {
366                         // Don't let the bind finish
367                         cancelHeadsUpBind(posted.entry)
368                     }
369                 }
370             } else if (posted.shouldHeadsUpEver && posted.shouldHeadsUpAgain) {
371                 // This notification was updated to be heads up, show it!
372                 bindForAsyncHeadsUp(posted)
373             }
374         }
375     }
376 
377     private fun cancelHeadsUpBind(entry: NotificationEntry) {
378         mEntriesBindingUntil.remove(entry.key)
379         mHeadsUpViewBinder.abortBindCallback(entry)
380     }
381 
382     private fun bindForAsyncHeadsUp(posted: PostedEntry) {
383         // TODO: Add a guarantee to bindHeadsUpView of some kind of callback if the bind is
384         //  cancelled so that we don't need to have this sad timeout hack.
385         mEntriesBindingUntil[posted.key] = mNow + BIND_TIMEOUT
386         mHeadsUpViewBinder.bindHeadsUpView(posted.entry, this::onHeadsUpViewBound)
387     }
388 
389     private val mNotifCollectionListener = object : NotifCollectionListener {
390         /**
391          * Notification was just added and if it should heads up, bind the view and then show it.
392          */
393         override fun onEntryAdded(entry: NotificationEntry) {
394             // First check whether this notification should launch a full screen intent, and
395             // launch it if needed.
396             val fsiDecision =
397                 mVisualInterruptionDecisionProvider.makeUnloggedFullScreenIntentDecision(entry)
398             mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(fsiDecision)
399             if (fsiDecision.shouldInterrupt) {
400                 mLaunchFullScreenIntentProvider.launchFullScreenIntent(entry)
401             } else if (fsiDecision.wouldInterruptWithoutDnd) {
402                 // If DND was the only reason this entry was suppressed, note it for potential
403                 // reconsideration on later ranking updates.
404                 addForFSIReconsideration(entry, mSystemClock.currentTimeMillis())
405             }
406 
407             // makeAndLogHeadsUpDecision includes check for whether this notification should be
408             // filtered
409             val shouldHeadsUpEver =
410                 mVisualInterruptionDecisionProvider.makeAndLogHeadsUpDecision(entry).shouldInterrupt
411             mPostedEntries[entry.key] = PostedEntry(
412                 entry,
413                 wasAdded = true,
414                 wasUpdated = false,
415                 shouldHeadsUpEver = shouldHeadsUpEver,
416                 shouldHeadsUpAgain = true,
417                 isHeadsUpEntry = false,
418                 isBinding = false,
419             )
420 
421             // Record the last updated time for this key
422             setUpdateTime(entry, mSystemClock.currentTimeMillis())
423         }
424 
425         /**
426          * Notification could've updated to be heads up or not heads up. Even if it did update to
427          * heads up, if the notification specified that it only wants to heads up once, don't heads
428          * up again.
429          */
430         override fun onEntryUpdated(entry: NotificationEntry) {
431             val shouldHeadsUpEver =
432                 mVisualInterruptionDecisionProvider.makeAndLogHeadsUpDecision(entry).shouldInterrupt
433             val shouldHeadsUpAgain = shouldHunAgain(entry)
434             val isHeadsUpEntry = mHeadsUpManager.isHeadsUpEntry(entry.key)
435             val isBinding = isEntryBinding(entry)
436             val posted = mPostedEntries.compute(entry.key) { _, value ->
437                 value?.also { update ->
438                     update.wasUpdated = true
439                     update.shouldHeadsUpEver = shouldHeadsUpEver
440                     update.shouldHeadsUpAgain = update.shouldHeadsUpAgain || shouldHeadsUpAgain
441                     update.isHeadsUpEntry = isHeadsUpEntry
442                     update.isBinding = isBinding
443                 } ?: PostedEntry(
444                     entry,
445                     wasAdded = false,
446                     wasUpdated = true,
447                     shouldHeadsUpEver = shouldHeadsUpEver,
448                     shouldHeadsUpAgain = shouldHeadsUpAgain,
449                     isHeadsUpEntry = isHeadsUpEntry,
450                     isBinding = isBinding,
451                 )
452             }
453             // Handle cancelling heads up here, rather than in the OnBeforeFinalizeFilter, so that
454             // work can be done before the ShadeListBuilder is run. This prevents re-entrant
455             // behavior between this Coordinator, HeadsUpManager, and VisualStabilityManager.
456             if (posted?.shouldHeadsUpEver == false) {
457                 if (posted.isHeadsUpEntry) {
458                     // We don't want this to be interrupting anymore, let's remove it
459                     mHeadsUpManager.removeNotification(posted.key, false /*removeImmediately*/)
460                 } else if (posted.isBinding) {
461                     // Don't let the bind finish
462                     cancelHeadsUpBind(posted.entry)
463                 }
464             }
465 
466             // Update last updated time for this entry
467             setUpdateTime(entry, mSystemClock.currentTimeMillis())
468         }
469 
470         /**
471          * Stop showing as heads up once removed from the notification collection
472          */
473         override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
474             mPostedEntries.remove(entry.key)
475             mEntriesUpdateTimes.remove(entry.key)
476             cancelHeadsUpBind(entry)
477             val entryKey = entry.key
478             if (mHeadsUpManager.isHeadsUpEntry(entryKey)) {
479                 // TODO: This should probably know the RemoteInputCoordinator's conditions,
480                 //  or otherwise reference that coordinator's state, rather than replicate its logic
481                 val removeImmediatelyForRemoteInput = (mRemoteInputManager.isSpinning(entryKey) &&
482                         !NotificationRemoteInputManager.FORCE_REMOTE_INPUT_HISTORY)
483                 mHeadsUpManager.removeNotification(entry.key, removeImmediatelyForRemoteInput)
484             }
485         }
486 
487         override fun onEntryCleanUp(entry: NotificationEntry) {
488             mHeadsUpViewBinder.abortBindCallback(entry)
489         }
490 
491         /**
492          * Identify notifications whose heads-up state changes when the notification rankings are
493          * updated, and have those changed notifications heads up if necessary.
494          *
495          * This method will occur after any operations in onEntryAdded or onEntryUpdated, so any
496          * handling of ranking changes needs to take into account that we may have just made a
497          * PostedEntry for some of these notifications.
498          */
499         override fun onRankingApplied() {
500             // Because a ranking update may cause some notifications that are no longer (or were
501             // never) in mPostedEntries to need to heads up, we need to check every notification
502             // known to the pipeline.
503             for (entry in mNotifPipeline.allNotifs) {
504                 // Only consider entries that are recent enough, since we want to apply a fairly
505                 // strict threshold for when an entry should be updated via only ranking and not an
506                 // app-provided notification update.
507                 if (!isNewEnoughForRankingUpdate(entry)) continue
508 
509                 // The only entries we consider heads up for here are entries that have never
510                 // interrupted and that now say they should heads up or FSI; if they've heads uped in
511                 // the past, we don't want to incorrectly heads up a second time if there wasn't an
512                 // explicit notification update.
513                 if (entry.hasInterrupted()) continue
514 
515                 // Before potentially allowing heads-up, check for any candidates for a FSI launch.
516                 // Any entry that is a candidate meets two criteria:
517                 //   - was suppressed from FSI launch only by a DND suppression
518                 //   - is within the recency window for reconsideration
519                 // If any of these entries are no longer suppressed, launch the FSI now.
520                 if (isCandidateForFSIReconsideration(entry)) {
521                     val decision =
522                         mVisualInterruptionDecisionProvider.makeUnloggedFullScreenIntentDecision(
523                             entry
524                         )
525                     if (decision.shouldInterrupt) {
526                         // Log both the launch of the full screen and also that this was via a
527                         // ranking update, and finally revoke candidacy for FSI reconsideration
528                         mLogger.logEntryUpdatedToFullScreen(entry.key, decision.logReason)
529                         mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(decision)
530                         mLaunchFullScreenIntentProvider.launchFullScreenIntent(entry)
531                         mFSIUpdateCandidates.remove(entry.key)
532 
533                         // if we launch the FSI then this is no longer a candidate for HUN
534                         continue
535                     } else if (decision.wouldInterruptWithoutDnd) {
536                         // decision has not changed; no need to log
537                     } else {
538                         // some other condition is now blocking FSI; log that and revoke candidacy
539                         // for FSI reconsideration
540                         mLogger.logEntryDisqualifiedFromFullScreen(entry.key, decision.logReason)
541                         mVisualInterruptionDecisionProvider.logFullScreenIntentDecision(decision)
542                         mFSIUpdateCandidates.remove(entry.key)
543                     }
544                 }
545 
546                 // The cases where we should consider this notification to be updated:
547                 // - if this entry is not present in PostedEntries, and is now in a shouldHeadsUp
548                 //   state
549                 // - if it is present in PostedEntries and the previous state of shouldHeadsUp
550                 //   differs from the updated one
551                 val decision =
552                     mVisualInterruptionDecisionProvider.makeUnloggedHeadsUpDecision(entry)
553                 val shouldHeadsUpEver = decision.shouldInterrupt
554                 val postedShouldHeadsUpEver = mPostedEntries[entry.key]?.shouldHeadsUpEver ?: false
555                 val shouldUpdateEntry = postedShouldHeadsUpEver != shouldHeadsUpEver
556 
557                 if (shouldUpdateEntry) {
558                     mLogger.logEntryUpdatedByRanking(
559                         entry.key,
560                         shouldHeadsUpEver,
561                         decision.logReason
562                     )
563                     onEntryUpdated(entry)
564                 }
565             }
566         }
567     }
568 
569     /**
570      * Checks whether an update for a notification warrants an heads up for the user.
571      */
572     private fun shouldHunAgain(entry: NotificationEntry): Boolean {
573         return (!entry.hasInterrupted() ||
574                 (entry.sbn.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE) == 0)
575     }
576 
577     /**
578      * Sets the updated time for the given entry to the specified time.
579      */
580     @VisibleForTesting
581     fun setUpdateTime(entry: NotificationEntry, time: Long) {
582         mEntriesUpdateTimes[entry.key] = time
583     }
584 
585     /**
586      * Add the entry to the list of entries potentially considerable for FSI ranking update, where
587      * the provided time is the time the entry was added.
588      */
589     @VisibleForTesting
590     fun addForFSIReconsideration(entry: NotificationEntry, time: Long) {
591         mFSIUpdateCandidates[entry.key] = time
592     }
593 
594     /**
595      * Checks whether the entry is new enough to be updated via ranking update.
596      * We want to avoid updating an entry too long after it was originally posted/updated when we're
597      * only reacting to a ranking change, as relevant ranking updates are expected to come in
598      * fairly soon after the posting of a notification.
599      */
600     private fun isNewEnoughForRankingUpdate(entry: NotificationEntry): Boolean {
601         // If we don't have an update time for this key, default to "too old"
602         if (!mEntriesUpdateTimes.containsKey(entry.key)) return false
603 
604         val updateTime = mEntriesUpdateTimes[entry.key] ?: return false
605         return (mSystemClock.currentTimeMillis() - updateTime) <= MAX_RANKING_UPDATE_DELAY_MS
606     }
607 
608     /**
609      * Checks whether the entry is present new enough for reconsideration for full screen launch.
610      * The time window is the same as for ranking update, but this doesn't allow a potential update
611      * to an entry with full screen intent to count for timing purposes.
612      */
613     private fun isCandidateForFSIReconsideration(entry: NotificationEntry): Boolean {
614         val addedTime = mFSIUpdateCandidates[entry.key] ?: return false
615         return (mSystemClock.currentTimeMillis() - addedTime) <= MAX_RANKING_UPDATE_DELAY_MS
616     }
617 
618     private fun cleanUpEntryTimes() {
619         // Because we won't update entries that are older than this amount of time anyway, clean
620         // up any entries that are too old to notify from both the general and FSI specific lists.
621 
622         // Anything newer than this time is still within the window.
623         val timeThreshold = mSystemClock.currentTimeMillis() - MAX_RANKING_UPDATE_DELAY_MS
624 
625         val toRemove = ArraySet<String>()
626         for ((key, updateTime) in mEntriesUpdateTimes) {
627             if (updateTime == null || timeThreshold > updateTime) {
628                 toRemove.add(key)
629             }
630         }
631         mEntriesUpdateTimes.removeAll(toRemove)
632 
633         val toRemoveForFSI = ArraySet<String>()
634         for ((key, addedTime) in mFSIUpdateCandidates) {
635             if (addedTime == null || timeThreshold > addedTime) {
636                 toRemoveForFSI.add(key)
637             }
638         }
639         mFSIUpdateCandidates.removeAll(toRemoveForFSI)
640     }
641 
642     /**
643      * When an action is pressed on a notification, make sure we don't lifetime-extend it in the
644      * future by informing the HeadsUpManager, and make sure we don't keep lifetime-extending it if
645      * we already are.
646      *
647      * @see HeadsUpManager.setUserActionMayIndirectlyRemove
648      * @see HeadsUpManager.canRemoveImmediately
649      */
650     private val mActionPressListener = Consumer<NotificationEntry> { entry ->
651         mHeadsUpManager.setUserActionMayIndirectlyRemove(entry)
652         mExecutor.execute { endNotifLifetimeExtensionIfExtended(entry) }
653     }
654 
655     private val mLifetimeExtender = object : NotifLifetimeExtender {
656         override fun getName() = TAG
657 
658         override fun setCallback(callback: OnEndLifetimeExtensionCallback) {
659             mEndLifetimeExtension = callback
660         }
661 
662         override fun maybeExtendLifetime(entry: NotificationEntry, reason: Int): Boolean {
663             if (mHeadsUpManager.canRemoveImmediately(entry.key)) {
664                 return false
665             }
666             if (isSticky(entry)) {
667                 val removeAfterMillis = mHeadsUpManager.getEarliestRemovalTime(entry.key)
668                 mNotifsExtendingLifetime[entry] = mExecutor.executeDelayed({
669                     mHeadsUpManager.removeNotification(entry.key, /* releaseImmediately */ true)
670                 }, removeAfterMillis)
671             } else {
672                 mExecutor.execute {
673                     mHeadsUpManager.removeNotification(entry.key, /* releaseImmediately */ false)
674                 }
675                 mNotifsExtendingLifetime[entry] = null
676             }
677             return true
678         }
679 
680         override fun cancelLifetimeExtension(entry: NotificationEntry) {
681             mNotifsExtendingLifetime.remove(entry)?.run()
682         }
683     }
684 
685     private val mNotifPromoter = object : NotifPromoter(TAG) {
686         override fun shouldPromoteToTopLevel(entry: NotificationEntry): Boolean =
687             isGoingToShowHunNoRetract(entry)
688     }
689 
690     val sectioner = object : NotifSectioner("HeadsUp", BUCKET_HEADS_UP) {
691         override fun isInSection(entry: ListEntry): Boolean =
692             // TODO: This check won't notice if a child of the group is going to HUN...
693             isGoingToShowHunNoRetract(entry)
694 
695         override fun getComparator(): NotifComparator {
696             return object : NotifComparator("HeadsUp") {
697                 override fun compare(o1: ListEntry, o2: ListEntry): Int =
698                     mHeadsUpManager.compare(o1.representativeEntry, o2.representativeEntry)
699             }
700         }
701 
702         override fun getHeaderNodeController(): NodeController? =
703             // TODO: remove SHOW_ALL_SECTIONS, this redundant method, and mIncomingHeaderController
704             if (RankingCoordinator.SHOW_ALL_SECTIONS) mIncomingHeaderController else null
705     }
706 
707     private val mOnHeadsUpChangedListener = object : OnHeadsUpChangedListener {
708         override fun onHeadsUpStateChanged(entry: NotificationEntry, isHeadsUp: Boolean) {
709             if (!isHeadsUp) {
710                 mNotifPromoter.invalidateList("headsUpEnded: ${entry.logKey}")
711                 mHeadsUpViewBinder.unbindHeadsUpView(entry)
712                 endNotifLifetimeExtensionIfExtended(entry)
713             }
714         }
715     }
716 
717     private fun isSticky(entry: NotificationEntry) = mHeadsUpManager.isSticky(entry.key)
718 
719     private fun isEntryBinding(entry: ListEntry): Boolean {
720         val bindingUntil = mEntriesBindingUntil[entry.key]
721         return bindingUntil != null && bindingUntil >= mNow
722     }
723 
724     /**
725      * Whether the notification is already heads up or binding so that it can imminently heads up
726      */
727     private fun isAttemptingToShowHun(entry: ListEntry) =
728         mHeadsUpManager.isHeadsUpEntry(entry.key) || isEntryBinding(entry)
729 
730     /**
731      * Whether the notification is already heads up/binding per [isAttemptingToShowHun] OR if it
732      * has been updated so that it should heads up this update.  This method is permissive because
733      * it returns `true` even if the update would (in isolation of its group) cause the heads up to
734      * be retracted.  This is important for not retracting transferred group heads ups.
735      */
736     private fun isGoingToShowHunNoRetract(entry: ListEntry) =
737         mPostedEntries[entry.key]?.calculateShouldBeHeadsUpNoRetract ?: isAttemptingToShowHun(entry)
738 
739     /**
740      * If the notification has been updated, then whether it should HUN in isolation, otherwise
741      * defers to the already heads up/binding state of [isAttemptingToShowHun].  This method is
742      * strict because any update which would revoke the heads up supersedes the current
743      * heads up/binding state.
744      */
745     private fun isGoingToShowHunStrict(entry: ListEntry) =
746         mPostedEntries[entry.key]?.calculateShouldBeHeadsUpStrict ?: isAttemptingToShowHun(entry)
747 
748     private fun endNotifLifetimeExtensionIfExtended(entry: NotificationEntry) {
749         if (mNotifsExtendingLifetime.contains(entry)) {
750             mNotifsExtendingLifetime.remove(entry)?.run()
751             mEndLifetimeExtension?.onEndLifetimeExtension(mLifetimeExtender, entry)
752         }
753     }
754 
755     companion object {
756         private const val TAG = "HeadsUpCoordinator"
757         private const val BIND_TIMEOUT = 1000L
758 
759         // This value is set to match MAX_SOUND_DELAY_MS in NotificationRecord.
760         private const val MAX_RANKING_UPDATE_DELAY_MS: Long = 2000
761     }
762 
763     data class PostedEntry(
764         val entry: NotificationEntry,
765         val wasAdded: Boolean,
766         var wasUpdated: Boolean,
767         var shouldHeadsUpEver: Boolean,
768         var shouldHeadsUpAgain: Boolean,
769         var isHeadsUpEntry: Boolean,
770         var isBinding: Boolean,
771     ) {
772         val key = entry.key
773         val isHeadsUpAlready: Boolean
774             get() = isHeadsUpEntry || isBinding
775         val calculateShouldBeHeadsUpStrict: Boolean
776             get() = shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain || isHeadsUpAlready)
777         val calculateShouldBeHeadsUpNoRetract: Boolean
778             get() = isHeadsUpAlready || (shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain))
779     }
780 }
781 
782 private enum class GroupLocation { Detached, Isolated, Summary, Child }
783 
getLocationnull784 private fun Map<String, GroupLocation>.getLocation(key: String): GroupLocation =
785     getOrDefault(key, GroupLocation.Detached)
786 
787 /**
788  * Invokes the given block with a [HunMutator] that defers all HUN removals. This ensures that the
789  * HeadsUpManager is notified of additions before removals, which prevents a glitch where the
790  * HeadsUpManager temporarily believes that nothing is heads up, causing bad re-entrant behavior.
791  */
792 private fun <R> HeadsUpManager.modifyHuns(block: (HunMutator) -> R): R {
793     val mutator = HunMutatorImpl(this)
794     return block(mutator).also { mutator.commitModifications() }
795 }
796 
797 /** Mutates the HeadsUp state of notifications. */
798 private interface HunMutator {
updateNotificationnull799     fun updateNotification(key: String, shouldHeadsUpAgain: Boolean)
800     fun removeNotification(key: String, releaseImmediately: Boolean)
801 }
802 
803 /**
804  * [HunMutator] implementation that defers removing notifications from the HeadsUpManager until
805  * after additions/updates.
806  */
807 private class HunMutatorImpl(private val headsUpManager: HeadsUpManager) : HunMutator {
808     private val deferred = mutableListOf<Pair<String, Boolean>>()
809 
810     override fun updateNotification(key: String, shouldHeadsUpAgain: Boolean) {
811         headsUpManager.updateNotification(key, shouldHeadsUpAgain)
812     }
813 
814     override fun removeNotification(key: String, releaseImmediately: Boolean) {
815         val args = Pair(key, releaseImmediately)
816         deferred.add(args)
817     }
818 
819     fun commitModifications() {
820         deferred.forEach { (key, releaseImmediately) ->
821             headsUpManager.removeNotification(key, releaseImmediately)
822         }
823         deferred.clear()
824     }
825 }
826