Fix heads up/ambient content inflation w/ groups.
This CL fixes lazy content inflation for alerting notifications in
groups by inflating them on the fly if we would transfer to a
notification that does not have its content inflated. We introduce a
helper class here to explicitly deal with the transfer logic,
refactoring a lot of the GroupManager code out.
This removes the previous workaround to always inflate heads up +
ambient views.
Bug: 111809944
Fixes: 111809944
Fixes: 117933032
Fixes: 117894786
Test: runtest systemui
Test: manual, posted groups that trigger a transfer and a transfer back.
Group alerts behaved as expected.
Change-Id: I9b5ec4c8bdeea20707874d90213dcd1d22d8b503
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationGroupAlertTransferHelper.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationGroupAlertTransferHelper.java
new file mode 100644
index 0000000..d38d30e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationGroupAlertTransferHelper.java
@@ -0,0 +1,460 @@
+/*
+ * 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.app.Notification;
+import android.os.SystemClock;
+import android.service.notification.StatusBarNotification;
+import android.util.ArrayMap;
+
+import com.android.systemui.Dependency;
+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.StatusBarStateController;
+import com.android.systemui.statusbar.StatusBarStateController.StateListener;
+import com.android.systemui.statusbar.notification.NotificationData.Entry;
+import com.android.systemui.statusbar.notification.row.NotificationInflater.AsyncInflationTask;
+import com.android.systemui.statusbar.notification.row.NotificationInflater.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.Collection;
+import java.util.HashMap;
+import java.util.Objects;
+
+/**
+ * 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.
+ */
+public class NotificationGroupAlertTransferHelper implements OnGroupChangeListener,
+ 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<String, GroupAlertEntry> mGroupAlertEntries = new ArrayMap<>();
+
+ /**
+ * The list of entries currently inflating that should alert after inflation. Keyed by
+ * notification key.
+ */
+ private final ArrayMap<String, PendingAlertInfo> mPendingAlerts = new ArrayMap<>();
+
+ private HeadsUpManager mHeadsUpManager;
+ private final AmbientPulseManager mAmbientPulseManager =
+ Dependency.get(AmbientPulseManager.class);
+ private final NotificationGroupManager mGroupManager =
+ Dependency.get(NotificationGroupManager.class);
+
+ // 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.
+ /**
+ * Notifications that are currently inflating for the first time. Used to remove an incorrectly
+ * alerting notification faster.
+ */
+ private HashMap<String, Entry> mPendingNotifications;
+
+ private boolean mIsDozing;
+
+ public NotificationGroupAlertTransferHelper() {
+ Dependency.get(StatusBarStateController.class).addListener(this);
+ }
+
+ public void setHeadsUpManager(HeadsUpManager headsUpManager) {
+ mHeadsUpManager = headsUpManager;
+ }
+
+ public void setPendingEntries(HashMap<String, Entry> pendingNotifications) {
+ mPendingNotifications = pendingNotifications;
+ }
+
+ @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;
+ }
+
+ @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(Entry entry, boolean isAmbient) {
+ onAlertStateChanged(entry, isAmbient, mAmbientPulseManager);
+ }
+
+ @Override
+ public void onHeadsUpStateChanged(Entry entry, boolean isHeadsUp) {
+ onAlertStateChanged(entry, isHeadsUp, mHeadsUpManager);
+ }
+
+ private void onAlertStateChanged(Entry entry, boolean isAlerting,
+ AlertingNotificationManager alertManager) {
+ if (isAlerting && mGroupManager.isSummaryOfSuppressedGroup(entry.notification)) {
+ handleSuppressedSummaryAlerted(entry, alertManager);
+ }
+ }
+
+ /**
+ * Called when the entry's reinflation has finished. If there is an alert pending, we then
+ * show the alert.
+ *
+ * @param entry entry whose inflation has finished
+ */
+ public void onInflationFinished(@NonNull Entry 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.row.freeContentViewWhenSafe(alertInfo.mAlertManager.getContentFlag());
+ }
+ }
+ }
+
+ /**
+ * Called when the entry's reinflation has been aborted.
+ *
+ * @param entry entry whose inflation has been aborted
+ */
+ public void onInflationAborted(@NonNull Entry entry) {
+ GroupAlertEntry groupAlertEntry = mGroupAlertEntries.get(
+ mGroupManager.getGroupKey(entry.notification));
+ if (groupAlertEntry == null) {
+ return;
+ }
+ mPendingAlerts.remove(entry.key);
+ }
+
+ /**
+ * 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.
+ *
+ * @param entry entry that has been added
+ */
+ public void onPendingEntryAdded(@NonNull Entry entry) {
+ String groupKey = mGroupManager.getGroupKey(entry.notification);
+ GroupAlertEntry groupAlertEntry = mGroupAlertEntries.get(groupKey);
+ if (groupAlertEntry != null) {
+ checkShouldTransferBack(groupAlertEntry);
+ }
+ }
+
+ /**
+ * 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 (mPendingNotifications == null) {
+ return 0;
+ }
+ int number = 0;
+ Collection<Entry> values = mPendingNotifications.values();
+ for (Entry 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 (mPendingNotifications == null) {
+ return false;
+ }
+ Collection<Entry> values = mPendingNotifications.values();
+ for (Entry 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 Entry 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 Entry 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;
+ }
+
+ Entry child = mGroupManager.getLogicalChildren(summary.notification).iterator().next();
+ if (child != null) {
+ if (child.row.keepInParent()
+ || child.row.isRemoved()
+ || child.row.isDismissed()) {
+ // 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 Entry fromEntry, @NonNull Entry 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) {
+ Entry summary = groupAlertEntry.mGroup.summary;
+ AlertingNotificationManager alertManager = getActiveAlertManager();
+
+ if (!onlySummaryAlerts(summary)) {
+ return;
+ }
+ ArrayList<Entry> 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++) {
+ Entry 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 Entry entry,
+ @NonNull AlertingNotificationManager alertManager) {
+ @InflationFlag int contentFlag = alertManager.getContentFlag();
+ if (!entry.row.isInflationFlagSet(contentFlag)) {
+ // Take in the current alert manager in case it changes.
+ mPendingAlerts.put(entry.key, new PendingAlertInfo(alertManager));
+ entry.row.updateInflationFlag(contentFlag, true /* shouldInflate */);
+ entry.row.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(Entry 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 {
+ final AlertingNotificationManager mAlertManager;
+ /**
+ * 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(AlertingNotificationManager alertManager) {
+ 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;
+ }
+ 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;
+ }
+ }
+}