/* * Copyright (C) 2018 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.phone; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Notification; import android.os.SystemClock; import android.service.notification.StatusBarNotification; import android.util.ArrayMap; import com.android.internal.statusbar.NotificationVisibility; import com.android.systemui.Dependency; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.plugins.statusbar.StatusBarStateController.StateListener; import com.android.systemui.statusbar.AlertingNotificationManager; import com.android.systemui.statusbar.AmbientPulseManager; import com.android.systemui.statusbar.AmbientPulseManager.OnAmbientChangedListener; import com.android.systemui.statusbar.InflationTask; import com.android.systemui.statusbar.notification.NotificationEntryListener; import com.android.systemui.statusbar.notification.NotificationEntryManager; import com.android.systemui.statusbar.notification.collection.NotificationEntry; import com.android.systemui.statusbar.notification.row.NotificationContentInflater.AsyncInflationTask; import com.android.systemui.statusbar.notification.row.NotificationContentInflater.InflationFlag; import com.android.systemui.statusbar.phone.NotificationGroupManager.NotificationGroup; import com.android.systemui.statusbar.phone.NotificationGroupManager.OnGroupChangeListener; import com.android.systemui.statusbar.policy.HeadsUpManager; import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener; import java.util.ArrayList; import java.util.Objects; import javax.inject.Inject; import javax.inject.Singleton; /** * A helper class dealing with the alert interactions between {@link NotificationGroupManager}, * {@link HeadsUpManager}, {@link AmbientPulseManager}. In particular, this class deals with keeping * the correct notification in a group alerting based off the group suppression. */ @Singleton public class NotificationGroupAlertTransferHelper implements OnHeadsUpChangedListener, OnAmbientChangedListener, StateListener { private static final long ALERT_TRANSFER_TIMEOUT = 300; /** * The list of entries containing group alert metadata for each group. Keyed by group key. */ private final ArrayMap mGroupAlertEntries = new ArrayMap<>(); /** * The list of entries currently inflating that should alert after inflation. Keyed by * notification key. */ private final ArrayMap mPendingAlerts = new ArrayMap<>(); private HeadsUpManager mHeadsUpManager; private final AmbientPulseManager mAmbientPulseManager = Dependency.get(AmbientPulseManager.class); private final NotificationGroupManager mGroupManager = Dependency.get(NotificationGroupManager.class); private NotificationEntryManager mEntryManager; private boolean mIsDozing; @Inject public NotificationGroupAlertTransferHelper() { Dependency.get(StatusBarStateController.class).addCallback(this); } /** Causes the TransferHelper to register itself as a listener to the appropriate classes. */ public void bind(NotificationEntryManager entryManager, NotificationGroupManager groupManager) { if (mEntryManager != null) { throw new IllegalStateException("Already bound."); } // TODO(b/119637830): It would be good if GroupManager already had all pending notifications // as normal children (i.e. add notifications to GroupManager before inflation) so that we // don't have to have this dependency. We'd also have to worry less about the suppression // not being up to date. mEntryManager = entryManager; mEntryManager.addNotificationEntryListener(mNotificationEntryListener); groupManager.addOnGroupChangeListener(mOnGroupChangeListener); } /** * Whether or not a notification has transferred its alert state to the notification and * the notification should alert after inflating. * * @param entry notification to check * @return true if the entry was transferred to and should inflate + alert */ public boolean isAlertTransferPending(@NonNull NotificationEntry entry) { PendingAlertInfo alertInfo = mPendingAlerts.get(entry.key); return alertInfo != null && alertInfo.isStillValid(); } public void setHeadsUpManager(HeadsUpManager headsUpManager) { mHeadsUpManager = headsUpManager; } @Override public void onStateChanged(int newState) {} @Override public void onDozingChanged(boolean isDozing) { if (mIsDozing != isDozing) { for (GroupAlertEntry groupAlertEntry : mGroupAlertEntries.values()) { groupAlertEntry.mLastAlertTransferTime = 0; groupAlertEntry.mAlertSummaryOnNextAddition = false; } } mIsDozing = isDozing; } private final OnGroupChangeListener mOnGroupChangeListener = new OnGroupChangeListener() { @Override public void onGroupCreated(NotificationGroup group, String groupKey) { mGroupAlertEntries.put(groupKey, new GroupAlertEntry(group)); } @Override public void onGroupRemoved(NotificationGroup group, String groupKey) { mGroupAlertEntries.remove(groupKey); } @Override public void onGroupSuppressionChanged(NotificationGroup group, boolean suppressed) { AlertingNotificationManager alertManager = getActiveAlertManager(); if (suppressed) { if (alertManager.isAlerting(group.summary.key)) { handleSuppressedSummaryAlerted(group.summary, alertManager); } } else { // Group summary can be null if we are no longer suppressed because the summary was // removed. In that case, we don't need to alert the summary. if (group.summary == null) { return; } GroupAlertEntry groupAlertEntry = mGroupAlertEntries.get(mGroupManager.getGroupKey( group.summary.notification)); // Group is no longer suppressed. We should check if we need to transfer the alert // back to the summary now that it's no longer suppressed. if (groupAlertEntry.mAlertSummaryOnNextAddition) { if (!alertManager.isAlerting(group.summary.key)) { alertNotificationWhenPossible(group.summary, alertManager); } groupAlertEntry.mAlertSummaryOnNextAddition = false; } else { checkShouldTransferBack(groupAlertEntry); } } } }; @Override public void onAmbientStateChanged(NotificationEntry entry, boolean isAmbient) { onAlertStateChanged(entry, isAmbient, mAmbientPulseManager); } @Override public void onHeadsUpStateChanged(NotificationEntry entry, boolean isHeadsUp) { onAlertStateChanged(entry, isHeadsUp, mHeadsUpManager); } private void onAlertStateChanged(NotificationEntry entry, boolean isAlerting, AlertingNotificationManager alertManager) { if (isAlerting && mGroupManager.isSummaryOfSuppressedGroup(entry.notification)) { handleSuppressedSummaryAlerted(entry, alertManager); } } private final NotificationEntryListener mNotificationEntryListener = new NotificationEntryListener() { // Called when a new notification has been posted but is not inflated yet. We use this to // see as early as we can if we need to abort a transfer. @Override public void onPendingEntryAdded(NotificationEntry entry) { String groupKey = mGroupManager.getGroupKey(entry.notification); GroupAlertEntry groupAlertEntry = mGroupAlertEntries.get(groupKey); if (groupAlertEntry != null) { checkShouldTransferBack(groupAlertEntry); } } // Called when the entry's reinflation has finished. If there is an alert pending, we // then show the alert. @Override public void onEntryReinflated(NotificationEntry entry) { PendingAlertInfo alertInfo = mPendingAlerts.remove(entry.key); if (alertInfo != null) { if (alertInfo.isStillValid()) { alertNotificationWhenPossible(entry, getActiveAlertManager()); } else { // The transfer is no longer valid. Free the content. entry.getRow().freeContentViewWhenSafe( alertInfo.mAlertManager.getContentFlag()); } } } @Override public void onEntryRemoved( @Nullable NotificationEntry entry, NotificationVisibility visibility, boolean removedByUser) { // Removes any alerts pending on this entry. Note that this will not stop any inflation // tasks started by a transfer, so this should only be used as clean-up for when // inflation is stopped and the pending alert no longer needs to happen. mPendingAlerts.remove(entry.key); } }; /** * Gets the number of new notifications pending inflation that will be added to the group * but currently aren't and should not alert. * * @param group group to check * @return the number of new notifications that will be added to the group */ private int getPendingChildrenNotAlerting(@NonNull NotificationGroup group) { if (mEntryManager == null) { return 0; } int number = 0; Iterable values = mEntryManager.getPendingNotificationsIterator(); for (NotificationEntry entry : values) { if (isPendingNotificationInGroup(entry, group) && onlySummaryAlerts(entry)) { number++; } } return number; } /** * Checks if the pending inflations will add children to this group. * * @param group group to check * @return true if a pending notification will add to this group */ private boolean pendingInflationsWillAddChildren(@NonNull NotificationGroup group) { if (mEntryManager == null) { return false; } Iterable values = mEntryManager.getPendingNotificationsIterator(); for (NotificationEntry entry : values) { if (isPendingNotificationInGroup(entry, group)) { return true; } } return false; } /** * Checks if a new pending notification will be added to the group. * * @param entry pending notification * @param group group to check * @return true if the notification will add to the group, false o/w */ private boolean isPendingNotificationInGroup(@NonNull NotificationEntry entry, @NonNull NotificationGroup group) { String groupKey = mGroupManager.getGroupKey(group.summary.notification); return mGroupManager.isGroupChild(entry.notification) && Objects.equals(mGroupManager.getGroupKey(entry.notification), groupKey) && !group.children.containsKey(entry.key); } /** * Handles the scenario where a summary that has been suppressed is alerted. A suppressed * summary should for all intents and purposes be invisible to the user and as a result should * not alert. When this is the case, it is our responsibility to pass the alert to the * appropriate child which will be the representative notification alerting for the group. * * @param summary the summary that is suppressed and alerting * @param alertManager the alert manager that manages the alerting summary */ private void handleSuppressedSummaryAlerted(@NonNull NotificationEntry summary, @NonNull AlertingNotificationManager alertManager) { StatusBarNotification sbn = summary.notification; GroupAlertEntry groupAlertEntry = mGroupAlertEntries.get(mGroupManager.getGroupKey(sbn)); if (!mGroupManager.isSummaryOfSuppressedGroup(summary.notification) || !alertManager.isAlerting(sbn.getKey()) || groupAlertEntry == null) { return; } if (pendingInflationsWillAddChildren(groupAlertEntry.mGroup)) { // New children will actually be added to this group, let's not transfer the alert. return; } NotificationEntry child = mGroupManager.getLogicalChildren(summary.notification).iterator().next(); if (child != null) { if (child.getRow().keepInParent() || child.isRowRemoved() || child.isRowDismissed()) { // The notification is actually already removed. No need to alert it. return; } if (!alertManager.isAlerting(child.key) && onlySummaryAlerts(summary)) { groupAlertEntry.mLastAlertTransferTime = SystemClock.elapsedRealtime(); } transferAlertState(summary, child, alertManager); } } /** * Transfers the alert state one entry to another. We remove the alert from the first entry * immediately to have the incorrect one up as short as possible. The second should alert * when possible. * * @param fromEntry entry to transfer alert from * @param toEntry entry to transfer to * @param alertManager alert manager for the alert type */ private void transferAlertState(@NonNull NotificationEntry fromEntry, @NonNull NotificationEntry toEntry, @NonNull AlertingNotificationManager alertManager) { alertManager.removeNotification(fromEntry.key, true /* releaseImmediately */); alertNotificationWhenPossible(toEntry, alertManager); } /** * Determines if we need to transfer the alert back to the summary from the child and does * so if needed. * * This can happen since notification groups are not delivered as a whole unit and it is * possible we erroneously transfer the alert from the summary to the child even though * more children are coming. Thus, if a child is added within a certain timeframe after we * transfer, we back out and alert the summary again. * * @param groupAlertEntry group alert entry to check */ private void checkShouldTransferBack(@NonNull GroupAlertEntry groupAlertEntry) { if (SystemClock.elapsedRealtime() - groupAlertEntry.mLastAlertTransferTime < ALERT_TRANSFER_TIMEOUT) { NotificationEntry summary = groupAlertEntry.mGroup.summary; AlertingNotificationManager alertManager = getActiveAlertManager(); if (!onlySummaryAlerts(summary)) { return; } ArrayList children = mGroupManager.getLogicalChildren(summary.notification); int numChildren = children.size(); int numPendingChildren = getPendingChildrenNotAlerting(groupAlertEntry.mGroup); numChildren += numPendingChildren; if (numChildren <= 1) { return; } boolean releasedChild = false; for (int i = 0; i < children.size(); i++) { NotificationEntry entry = children.get(i); if (onlySummaryAlerts(entry) && alertManager.isAlerting(entry.key)) { releasedChild = true; alertManager.removeNotification(entry.key, true /* releaseImmediately */); } if (mPendingAlerts.containsKey(entry.key)) { // This is the child that would've been removed if it was inflated. releasedChild = true; mPendingAlerts.get(entry.key).mAbortOnInflation = true; } } if (releasedChild && !alertManager.isAlerting(summary.key)) { boolean notifyImmediately = (numChildren - numPendingChildren) > 1; if (notifyImmediately) { alertNotificationWhenPossible(summary, alertManager); } else { // Should wait until the pending child inflates before alerting. groupAlertEntry.mAlertSummaryOnNextAddition = true; } groupAlertEntry.mLastAlertTransferTime = 0; } } } /** * Tries to alert the notification. If its content view is not inflated, we inflate and continue * when the entry finishes inflating the view. * * @param entry entry to show * @param alertManager alert manager for the alert type */ private void alertNotificationWhenPossible(@NonNull NotificationEntry entry, @NonNull AlertingNotificationManager alertManager) { @InflationFlag int contentFlag = alertManager.getContentFlag(); if (!entry.getRow().isInflationFlagSet(contentFlag)) { mPendingAlerts.put(entry.key, new PendingAlertInfo(entry, alertManager)); entry.getRow().updateInflationFlag(contentFlag, true /* shouldInflate */); entry.getRow().inflateViews(); return; } if (alertManager.isAlerting(entry.key)) { alertManager.updateNotification(entry.key, true /* alert */); } else { alertManager.showNotification(entry); } } private AlertingNotificationManager getActiveAlertManager() { return mIsDozing ? mAmbientPulseManager : mHeadsUpManager; } private boolean onlySummaryAlerts(NotificationEntry entry) { return entry.notification.getNotification().getGroupAlertBehavior() == Notification.GROUP_ALERT_SUMMARY; } /** * Information about a pending alert used to determine if the alert is still needed when * inflation completes. */ private class PendingAlertInfo { /** * The alert manager when the transfer is initiated. */ final AlertingNotificationManager mAlertManager; /** * The original notification when the transfer is initiated. This is used to determine if * the transfer is still valid if the notification is updated. */ final StatusBarNotification mOriginalNotification; final NotificationEntry mEntry; /** * The notification is still pending inflation but we've decided that we no longer need * the content view (e.g. suppression might have changed and we decided we need to transfer * back). However, there is no way to abort just this inflation if other inflation requests * have started (see {@link AsyncInflationTask#supersedeTask(InflationTask)}). So instead * we just flag it as aborted and free when it's inflated. */ boolean mAbortOnInflation; PendingAlertInfo(NotificationEntry entry, AlertingNotificationManager alertManager) { mOriginalNotification = entry.notification; mEntry = entry; mAlertManager = alertManager; } /** * Whether or not the pending alert is still valid and should still alert after inflation. * * @return true if the pending alert should still occur, false o/w */ private boolean isStillValid() { if (mAbortOnInflation) { // Notification is aborted due to the transfer being explicitly cancelled return false; } if (mAlertManager != getActiveAlertManager()) { // Alert manager has changed return false; } if (mEntry.notification.getGroupKey() != mOriginalNotification.getGroupKey()) { // Groups have changed return false; } if (mEntry.notification.getNotification().isGroupSummary() != mOriginalNotification.getNotification().isGroupSummary()) { // Notification has changed from group summary to not or vice versa return false; } return true; } } /** * Contains alert metadata for the notification group used to determine when/how the alert * should be transferred. */ private static class GroupAlertEntry { /** * The time when the last alert transfer from summary to child happened. */ long mLastAlertTransferTime; boolean mAlertSummaryOnNextAddition; final NotificationGroup mGroup; GroupAlertEntry(NotificationGroup group) { this.mGroup = group; } } }