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