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 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
getLocationnull778 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 {
updateNotificationnull793 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