blob: a61db4107c94dba8bd107fdce54c516646ce455d [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.statusbar.notification.collection.coordinator
import android.app.Notification
import android.app.Notification.GROUP_ALERT_SUMMARY
import android.util.ArrayMap
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.statusbar.NotificationRemoteInputManager
import com.android.systemui.statusbar.notification.collection.GroupEntry
import com.android.systemui.statusbar.notification.collection.ListEntry
import com.android.systemui.statusbar.notification.collection.NotifPipeline
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifComparator
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender.OnEndLifetimeExtensionCallback
import com.android.systemui.statusbar.notification.collection.render.NodeController
import com.android.systemui.statusbar.notification.dagger.IncomingHeader
import com.android.systemui.statusbar.notification.interruption.HeadsUpViewBinder
import com.android.systemui.statusbar.notification.interruption.NotificationInterruptStateProvider
import com.android.systemui.statusbar.notification.stack.BUCKET_HEADS_UP
import com.android.systemui.statusbar.policy.HeadsUpManager
import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener
import com.android.systemui.util.concurrency.DelayableExecutor
import com.android.systemui.util.time.SystemClock
import java.util.function.Consumer
import javax.inject.Inject
/**
* Coordinates heads up notification (HUN) interactions with the notification pipeline based on
* the HUN state reported by the [HeadsUpManager]. In this class we only consider one
* notification, in particular the [HeadsUpManager.getTopEntry], to be HeadsUpping at a
* time even though other notifications may be queued to heads up next.
*
* The current HUN, but not HUNs that are queued to heads up, will be:
* - Lifetime extended until it's no longer heads upping.
* - Promoted out of its group if it's a child of a group.
* - In the HeadsUpCoordinatorSection. Ordering is configured in [NotifCoordinators].
* - Removed from HeadsUpManager if it's removed from the NotificationCollection.
*
* Note: The inflation callback in [PreparationCoordinator] handles showing HUNs.
*/
@CoordinatorScope
class HeadsUpCoordinator @Inject constructor(
private val mLogger: HeadsUpCoordinatorLogger,
private val mSystemClock: SystemClock,
private val mHeadsUpManager: HeadsUpManager,
private val mHeadsUpViewBinder: HeadsUpViewBinder,
private val mNotificationInterruptStateProvider: NotificationInterruptStateProvider,
private val mRemoteInputManager: NotificationRemoteInputManager,
@IncomingHeader private val mIncomingHeaderController: NodeController,
@Main private val mExecutor: DelayableExecutor,
) : Coordinator {
private val mEntriesBindingUntil = ArrayMap<String, Long>()
private var mEndLifetimeExtension: OnEndLifetimeExtensionCallback? = null
private lateinit var mNotifPipeline: NotifPipeline
private var mNow: Long = -1
private val mPostedEntries = LinkedHashMap<String, PostedEntry>()
// notifs we've extended the lifetime for with cancellation callbacks
private val mNotifsExtendingLifetime = ArrayMap<NotificationEntry, Runnable?>()
override fun attach(pipeline: NotifPipeline) {
mNotifPipeline = pipeline
mHeadsUpManager.addListener(mOnHeadsUpChangedListener)
pipeline.addCollectionListener(mNotifCollectionListener)
pipeline.addOnBeforeTransformGroupsListener(::onBeforeTransformGroups)
pipeline.addOnBeforeFinalizeFilterListener(::onBeforeFinalizeFilter)
pipeline.addPromoter(mNotifPromoter)
pipeline.addNotificationLifetimeExtender(mLifetimeExtender)
mRemoteInputManager.addActionPressListener(mActionPressListener)
}
private fun onHeadsUpViewBound(entry: NotificationEntry) {
mHeadsUpManager.showNotification(entry)
mEntriesBindingUntil.remove(entry.key)
}
/**
* Once the pipeline starts running, we can look through posted entries and quickly process
* any that don't have groups, and thus will never gave a group alert edge case.
*/
fun onBeforeTransformGroups(list: List<ListEntry>) {
mNow = mSystemClock.currentTimeMillis()
if (mPostedEntries.isEmpty()) {
return
}
// Process all non-group adds/updates
mHeadsUpManager.modifyHuns { hunMutator ->
mPostedEntries.values.toList().forEach { posted ->
if (!posted.entry.sbn.isGroup) {
handlePostedEntry(posted, hunMutator, "non-group")
mPostedEntries.remove(posted.key)
}
}
}
}
/**
* Once we have a nearly final shade list (not including what's pruned for inflation reasons),
* we know that stability and [NotifPromoter]s have been applied, so we can use the location of
* notifications in this list to determine what kind of group alert behavior should happen.
*/
fun onBeforeFinalizeFilter(list: List<ListEntry>) = mHeadsUpManager.modifyHuns { hunMutator ->
// Nothing to do if there are no other adds/updates
if (mPostedEntries.isEmpty()) {
return@modifyHuns
}
// Calculate a bunch of information about the logical group and the locations of group
// entries in the nearly-finalized shade list. These may be used in the per-group loop.
val postedEntriesByGroup = mPostedEntries.values.groupBy { it.entry.sbn.groupKey }
val logicalMembersByGroup = mNotifPipeline.allNotifs.asSequence()
.filter { postedEntriesByGroup.contains(it.sbn.groupKey) }
.groupBy { it.sbn.groupKey }
val groupLocationsByKey: Map<String, GroupLocation> by lazy { getGroupLocationsByKey(list) }
mLogger.logEvaluatingGroups(postedEntriesByGroup.size)
// For each group, determine which notification(s) for a group should alert.
postedEntriesByGroup.forEach { (groupKey, postedEntries) ->
// get and classify the logical members
val logicalMembers = logicalMembersByGroup[groupKey] ?: emptyList()
val logicalSummary = logicalMembers.find { it.sbn.notification.isGroupSummary }
// Report the start of this group's evaluation
mLogger.logEvaluatingGroup(groupKey, postedEntries.size, logicalMembers.size)
// If there is no logical summary, then there is no alert to transfer
if (logicalSummary == null) {
postedEntries.forEach {
handlePostedEntry(it, hunMutator, scenario = "logical-summary-missing")
}
return@forEach
}
// If summary isn't wanted to be heads up, then there is no alert to transfer
if (!isGoingToShowHunStrict(logicalSummary)) {
postedEntries.forEach {
handlePostedEntry(it, hunMutator, scenario = "logical-summary-not-alerting")
}
return@forEach
}
// The group is alerting! Overall goals:
// - Maybe transfer its alert to a child
// - Also let any/all newly alerting children still alert
var childToReceiveParentAlert: NotificationEntry?
var targetType = "undefined"
// If the parent is alerting, always look at the posted notification with the newest
// 'when', and if it is isolated with GROUP_ALERT_SUMMARY, then it should receive the
// parent's alert.
childToReceiveParentAlert =
findAlertOverride(postedEntries, groupLocationsByKey::getLocation)
if (childToReceiveParentAlert != null) {
targetType = "alertOverride"
}
// If the summary is Detached and we have not picked a receiver of the alert, then we
// need to look for the best child to alert in place of the summary.
val isSummaryAttached = groupLocationsByKey.contains(logicalSummary.key)
if (!isSummaryAttached && childToReceiveParentAlert == null) {
childToReceiveParentAlert =
findBestTransferChild(logicalMembers, groupLocationsByKey::getLocation)
if (childToReceiveParentAlert != null) {
targetType = "bestChild"
}
}
// If there is no child to receive the parent alert, then just handle the posted entries
// and return.
if (childToReceiveParentAlert == null) {
postedEntries.forEach {
handlePostedEntry(it, hunMutator, scenario = "no-transfer-target")
}
return@forEach
}
// At this point we just need to initiate the transfer
val summaryUpdate = mPostedEntries[logicalSummary.key]
// If the summary was not attached, then remove the alert from the detached summary.
// Otherwise we can simply ignore its posted update.
if (!isSummaryAttached) {
val summaryUpdateForRemoval = summaryUpdate?.also {
it.shouldHeadsUpEver = false
} ?: PostedEntry(
logicalSummary,
wasAdded = false,
wasUpdated = false,
shouldHeadsUpEver = false,
shouldHeadsUpAgain = false,
isAlerting = mHeadsUpManager.isAlerting(logicalSummary.key),
isBinding = isEntryBinding(logicalSummary),
)
// If we transfer the alert and the summary isn't even attached, that means we
// should ensure the summary is no longer alerting, so we remove it here.
handlePostedEntry(
summaryUpdateForRemoval,
hunMutator,
scenario = "detached-summary-remove-alert")
} else if (summaryUpdate != null) {
mLogger.logPostedEntryWillNotEvaluate(
summaryUpdate,
reason = "attached-summary-transferred")
}
// Handle all posted entries -- if the child receiving the parent's alert is in the
// list, then set its flags to ensure it alerts.
var didAlertChildToReceiveParentAlert = false
postedEntries.asSequence()
.filter { it.key != logicalSummary.key }
.forEach { postedEntry ->
if (childToReceiveParentAlert.key == postedEntry.key) {
// Update the child's posted update so that it
postedEntry.shouldHeadsUpEver = true
postedEntry.shouldHeadsUpAgain = true
handlePostedEntry(
postedEntry,
hunMutator,
scenario = "child-alert-transfer-target-$targetType")
didAlertChildToReceiveParentAlert = true
} else {
handlePostedEntry(
postedEntry,
hunMutator,
scenario = "child-alert-non-target")
}
}
// If the child receiving the alert was not updated on this tick (which can happen in a
// standard alert transfer scenario), then construct an update so that we can apply it.
if (!didAlertChildToReceiveParentAlert) {
val posted = PostedEntry(
childToReceiveParentAlert,
wasAdded = false,
wasUpdated = false,
shouldHeadsUpEver = true,
shouldHeadsUpAgain = true,
isAlerting = mHeadsUpManager.isAlerting(childToReceiveParentAlert.key),
isBinding = isEntryBinding(childToReceiveParentAlert),
)
handlePostedEntry(
posted,
hunMutator,
scenario = "non-posted-child-alert-transfer-target-$targetType")
}
}
// After this method runs, all posted entries should have been handled (or skipped).
mPostedEntries.clear()
}
/**
* Find the posted child with the newest when, and return it if it is isolated and has
* GROUP_ALERT_SUMMARY so that it can be alerted.
*/
private fun findAlertOverride(
postedEntries: List<PostedEntry>,
locationLookupByKey: (String) -> GroupLocation,
): NotificationEntry? = postedEntries.asSequence()
.filter { posted -> !posted.entry.sbn.notification.isGroupSummary }
.sortedBy { posted -> -posted.entry.sbn.notification.`when` }
.firstOrNull()
?.let { posted ->
posted.entry.takeIf { entry ->
locationLookupByKey(entry.key) == GroupLocation.Isolated
&& entry.sbn.notification.groupAlertBehavior == GROUP_ALERT_SUMMARY
}
}
/**
* Of children which are attached, look for the child to receive the notification:
* First prefer children which were updated, then looking for the ones with the newest 'when'
*/
private fun findBestTransferChild(
logicalMembers: List<NotificationEntry>,
locationLookupByKey: (String) -> GroupLocation,
): NotificationEntry? = logicalMembers.asSequence()
.filter { !it.sbn.notification.isGroupSummary }
.filter { locationLookupByKey(it.key) != GroupLocation.Detached }
.sortedWith(compareBy(
{ !mPostedEntries.contains(it.key) },
{ -it.sbn.notification.`when` },
))
.firstOrNull()
private fun getGroupLocationsByKey(list: List<ListEntry>): Map<String, GroupLocation> =
mutableMapOf<String, GroupLocation>().also { map ->
list.forEach { topLevelEntry ->
when (topLevelEntry) {
is NotificationEntry -> map[topLevelEntry.key] = GroupLocation.Isolated
is GroupEntry -> {
topLevelEntry.summary?.let { summary ->
map[summary.key] = GroupLocation.Summary
}
topLevelEntry.children.forEach { child ->
map[child.key] = GroupLocation.Child
}
}
else -> error("unhandled type $topLevelEntry")
}
}
}
private fun handlePostedEntry(posted: PostedEntry, hunMutator: HunMutator, scenario: String) {
mLogger.logPostedEntryWillEvaluate(posted, scenario)
if (posted.wasAdded) {
if (posted.shouldHeadsUpEver) {
bindForAsyncHeadsUp(posted)
}
} else {
if (posted.isHeadsUpAlready) {
// NOTE: This might be because we're alerting (i.e. tracked by HeadsUpManager) OR
// it could be because we're binding, and that will affect the next step.
if (posted.shouldHeadsUpEver) {
// If alerting, we need to post an update. Otherwise we're still binding,
// and we can just let that finish.
if (posted.isAlerting) {
hunMutator.updateNotification(posted.key, posted.shouldHeadsUpAgain)
}
} else {
if (posted.isAlerting) {
// We don't want this to be interrupting anymore, let's remove it
hunMutator.removeNotification(posted.key, false /*removeImmediately*/)
} else {
// Don't let the bind finish
cancelHeadsUpBind(posted.entry)
}
}
} else if (posted.shouldHeadsUpEver && posted.shouldHeadsUpAgain) {
// This notification was updated to be heads up, show it!
bindForAsyncHeadsUp(posted)
}
}
}
private fun cancelHeadsUpBind(entry: NotificationEntry) {
mEntriesBindingUntil.remove(entry.key)
mHeadsUpViewBinder.abortBindCallback(entry)
}
private fun bindForAsyncHeadsUp(posted: PostedEntry) {
// TODO: Add a guarantee to bindHeadsUpView of some kind of callback if the bind is
// cancelled so that we don't need to have this sad timeout hack.
mEntriesBindingUntil[posted.key] = mNow + BIND_TIMEOUT
mHeadsUpViewBinder.bindHeadsUpView(posted.entry, this::onHeadsUpViewBound)
}
private val mNotifCollectionListener = object : NotifCollectionListener {
/**
* Notification was just added and if it should heads up, bind the view and then show it.
*/
override fun onEntryAdded(entry: NotificationEntry) {
// shouldHeadsUp includes check for whether this notification should be filtered
val shouldHeadsUpEver = mNotificationInterruptStateProvider.shouldHeadsUp(entry)
mPostedEntries[entry.key] = PostedEntry(
entry,
wasAdded = true,
wasUpdated = false,
shouldHeadsUpEver = shouldHeadsUpEver,
shouldHeadsUpAgain = true,
isAlerting = false,
isBinding = false,
)
}
/**
* Notification could've updated to be heads up or not heads up. Even if it did update to
* heads up, if the notification specified that it only wants to alert once, don't heads
* up again.
*/
override fun onEntryUpdated(entry: NotificationEntry) {
val shouldHeadsUpEver = mNotificationInterruptStateProvider.shouldHeadsUp(entry)
val shouldHeadsUpAgain = shouldHunAgain(entry)
val isAlerting = mHeadsUpManager.isAlerting(entry.key)
val isBinding = isEntryBinding(entry)
val posted = mPostedEntries.compute(entry.key) { _, value ->
value?.also { update ->
update.wasUpdated = true
update.shouldHeadsUpEver = update.shouldHeadsUpEver || shouldHeadsUpEver
update.shouldHeadsUpAgain = update.shouldHeadsUpAgain || shouldHeadsUpAgain
update.isAlerting = isAlerting
update.isBinding = isBinding
} ?: PostedEntry(
entry,
wasAdded = false,
wasUpdated = true,
shouldHeadsUpEver = shouldHeadsUpEver,
shouldHeadsUpAgain = shouldHeadsUpAgain,
isAlerting = isAlerting,
isBinding = isBinding,
)
}
// Handle cancelling alerts here, rather than in the OnBeforeFinalizeFilter, so that
// work can be done before the ShadeListBuilder is run. This prevents re-entrant
// behavior between this Coordinator, HeadsUpManager, and VisualStabilityManager.
if (posted?.shouldHeadsUpEver == false) {
if (posted.isAlerting) {
// We don't want this to be interrupting anymore, let's remove it
mHeadsUpManager.removeNotification(posted.key, false /*removeImmediately*/)
} else if (posted.isBinding) {
// Don't let the bind finish
cancelHeadsUpBind(posted.entry)
}
}
}
/**
* Stop alerting HUNs that are removed from the notification collection
*/
override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
mPostedEntries.remove(entry.key)
cancelHeadsUpBind(entry)
val entryKey = entry.key
if (mHeadsUpManager.isAlerting(entryKey)) {
// TODO: This should probably know the RemoteInputCoordinator's conditions,
// or otherwise reference that coordinator's state, rather than replicate its logic
val removeImmediatelyForRemoteInput = (mRemoteInputManager.isSpinning(entryKey) &&
!NotificationRemoteInputManager.FORCE_REMOTE_INPUT_HISTORY)
mHeadsUpManager.removeNotification(entry.key, removeImmediatelyForRemoteInput)
}
}
override fun onEntryCleanUp(entry: NotificationEntry) {
mHeadsUpViewBinder.abortBindCallback(entry)
}
}
/**
* Checks whether an update for a notification warrants an alert for the user.
*/
private fun shouldHunAgain(entry: NotificationEntry): Boolean {
return (!entry.hasInterrupted() ||
(entry.sbn.notification.flags and Notification.FLAG_ONLY_ALERT_ONCE) == 0)
}
/** When an action is pressed on a notification, end HeadsUp lifetime extension. */
private val mActionPressListener = Consumer<NotificationEntry> { entry ->
if (mNotifsExtendingLifetime.contains(entry)) {
val removeInMillis = mHeadsUpManager.getEarliestRemovalTime(entry.key)
mExecutor.executeDelayed({ endNotifLifetimeExtensionIfExtended(entry) }, removeInMillis)
}
}
private val mLifetimeExtender = object : NotifLifetimeExtender {
override fun getName() = TAG
override fun setCallback(callback: OnEndLifetimeExtensionCallback) {
mEndLifetimeExtension = callback
}
override fun maybeExtendLifetime(entry: NotificationEntry, reason: Int): Boolean {
if (mHeadsUpManager.canRemoveImmediately(entry.key)) {
return false
}
if (isSticky(entry)) {
val removeAfterMillis = mHeadsUpManager.getEarliestRemovalTime(entry.key)
mNotifsExtendingLifetime[entry] = mExecutor.executeDelayed({
mHeadsUpManager.removeNotification(entry.key, /* releaseImmediately */ true)
}, removeAfterMillis)
} else {
mExecutor.execute {
mHeadsUpManager.removeNotification(entry.key, /* releaseImmediately */ false)
}
mNotifsExtendingLifetime[entry] = null
}
return true
}
override fun cancelLifetimeExtension(entry: NotificationEntry) {
mNotifsExtendingLifetime.remove(entry)?.run()
}
}
private val mNotifPromoter = object : NotifPromoter(TAG) {
override fun shouldPromoteToTopLevel(entry: NotificationEntry): Boolean =
isGoingToShowHunNoRetract(entry)
}
val sectioner = object : NotifSectioner("HeadsUp", BUCKET_HEADS_UP) {
override fun isInSection(entry: ListEntry): Boolean =
// TODO: This check won't notice if a child of the group is going to HUN...
isGoingToShowHunNoRetract(entry)
override fun getComparator(): NotifComparator {
return object : NotifComparator("HeadsUp") {
override fun compare(o1: ListEntry, o2: ListEntry): Int =
mHeadsUpManager.compare(o1.representativeEntry, o2.representativeEntry)
}
}
override fun getHeaderNodeController(): NodeController? =
// TODO: remove SHOW_ALL_SECTIONS, this redundant method, and mIncomingHeaderController
if (RankingCoordinator.SHOW_ALL_SECTIONS) mIncomingHeaderController else null
}
private val mOnHeadsUpChangedListener = object : OnHeadsUpChangedListener {
override fun onHeadsUpStateChanged(entry: NotificationEntry, isHeadsUp: Boolean) {
if (!isHeadsUp) {
mNotifPromoter.invalidateList()
mHeadsUpViewBinder.unbindHeadsUpView(entry)
endNotifLifetimeExtensionIfExtended(entry)
}
}
}
private fun isSticky(entry: NotificationEntry) = mHeadsUpManager.isSticky(entry.key)
private fun isEntryBinding(entry: ListEntry): Boolean {
val bindingUntil = mEntriesBindingUntil[entry.key]
return bindingUntil != null && bindingUntil >= mNow
}
/**
* Whether the notification is already alerting or binding so that it can imminently alert
*/
private fun isAttemptingToShowHun(entry: ListEntry) =
mHeadsUpManager.isAlerting(entry.key) || isEntryBinding(entry)
/**
* Whether the notification is already alerting/binding per [isAttemptingToShowHun] OR if it
* has been updated so that it should alert this update. This method is permissive because it
* returns `true` even if the update would (in isolation of its group) cause the alert to be
* retracted. This is important for not retracting transferred group alerts.
*/
private fun isGoingToShowHunNoRetract(entry: ListEntry) =
mPostedEntries[entry.key]?.calculateShouldBeHeadsUpNoRetract ?: isAttemptingToShowHun(entry)
/**
* If the notification has been updated, then whether it should HUN in isolation, otherwise
* defers to the already alerting/binding state of [isAttemptingToShowHun]. This method is
* strict because any update which would revoke the alert supersedes the current
* alerting/binding state.
*/
private fun isGoingToShowHunStrict(entry: ListEntry) =
mPostedEntries[entry.key]?.calculateShouldBeHeadsUpStrict ?: isAttemptingToShowHun(entry)
private fun endNotifLifetimeExtensionIfExtended(entry: NotificationEntry) {
if (mNotifsExtendingLifetime.contains(entry)) {
mNotifsExtendingLifetime.remove(entry)?.run()
mEndLifetimeExtension?.onEndLifetimeExtension(mLifetimeExtender, entry)
}
}
companion object {
private const val TAG = "HeadsUpCoordinator"
private const val BIND_TIMEOUT = 1000L
}
data class PostedEntry(
val entry: NotificationEntry,
val wasAdded: Boolean,
var wasUpdated: Boolean,
var shouldHeadsUpEver: Boolean,
var shouldHeadsUpAgain: Boolean,
var isAlerting: Boolean,
var isBinding: Boolean,
) {
val key = entry.key
val isHeadsUpAlready: Boolean
get() = isAlerting || isBinding
val calculateShouldBeHeadsUpStrict: Boolean
get() = shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain || isHeadsUpAlready)
val calculateShouldBeHeadsUpNoRetract: Boolean
get() = isHeadsUpAlready || (shouldHeadsUpEver && (wasAdded || shouldHeadsUpAgain))
}
}
private enum class GroupLocation { Detached, Isolated, Summary, Child }
private fun Map<String, GroupLocation>.getLocation(key: String): GroupLocation =
getOrDefault(key, GroupLocation.Detached)
/**
* Invokes the given block with a [HunMutator] that defers all HUN removals. This ensures that the
* HeadsUpManager is notified of additions before removals, which prevents a glitch where the
* HeadsUpManager temporarily believes that nothing is alerting, causing bad re-entrant behavior.
*/
private fun <R> HeadsUpManager.modifyHuns(block: (HunMutator) -> R): R {
val mutator = HunMutatorImpl(this)
return block(mutator).also { mutator.commitModifications() }
}
/** Mutates the HeadsUp state of notifications. */
private interface HunMutator {
fun updateNotification(key: String, alert: Boolean)
fun removeNotification(key: String, releaseImmediately: Boolean)
}
/**
* [HunMutator] implementation that defers removing notifications from the HeadsUpManager until
* after additions/updates.
*/
private class HunMutatorImpl(private val headsUpManager: HeadsUpManager) : HunMutator {
private val deferred = mutableListOf<Pair<String, Boolean>>()
override fun updateNotification(key: String, alert: Boolean) {
headsUpManager.updateNotification(key, alert)
}
override fun removeNotification(key: String, releaseImmediately: Boolean) {
val args = Pair(key, releaseImmediately)
deferred.add(args)
}
fun commitModifications() {
deferred.forEach { (key, releaseImmediately) ->
headsUpManager.removeNotification(key, releaseImmediately)
}
deferred.clear()
}
}