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