summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author TreeHugger Robot <treehugger-gerrit@google.com> 2020-02-07 16:18:22 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2020-02-07 16:18:22 +0000
commit2690ffbbe678a587c30ba1f62e5626caa88ca7a2 (patch)
treeb3c2a47fae2eeaf20595216c9a0ed044a50074ce
parentc34c238af65920be00e25c9c1669e6fc56a992f0 (diff)
parenta53fb0db1638cc67f2ae816dd445aed453db5d5d (diff)
Merge "Add BubbleCoordinator"
-rw-r--r--packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java332
-rw-r--r--packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java11
-rw-r--r--packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java11
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java10
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java76
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java11
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java4
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/BubbleCoordinator.java164
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java2
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt8
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifDismissInterceptor.java68
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java4
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java141
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java842
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java204
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java20
16 files changed, 1738 insertions, 170 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index 792b9403dffd..05838abe184a 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -16,7 +16,6 @@
package com.android.systemui.bubbles;
-import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY;
import static android.app.Notification.FLAG_BUBBLE;
import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL_ALL;
@@ -69,18 +68,28 @@ import androidx.annotation.Nullable;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.statusbar.IStatusBarService;
+import com.android.internal.statusbar.NotificationVisibility;
+import com.android.systemui.DumpController;
+import com.android.systemui.Dumpable;
import com.android.systemui.R;
+import com.android.systemui.bubbles.BubbleController.BubbleExpandListener;
+import com.android.systemui.bubbles.BubbleController.BubbleStateChangeListener;
+import com.android.systemui.bubbles.BubbleController.NotifCallback;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.shared.system.ActivityManagerWrapper;
import com.android.systemui.shared.system.PinnedStackListenerForwarder;
import com.android.systemui.shared.system.TaskStackChangeListener;
import com.android.systemui.shared.system.WindowManagerWrapper;
+import com.android.systemui.statusbar.FeatureFlags;
import com.android.systemui.statusbar.NotificationLockscreenUserManager;
import com.android.systemui.statusbar.NotificationRemoveInterceptor;
import com.android.systemui.statusbar.notification.NotificationEntryListener;
import com.android.systemui.statusbar.notification.NotificationEntryManager;
import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider;
+import com.android.systemui.statusbar.notification.collection.NotifCollection;
+import com.android.systemui.statusbar.notification.collection.NotifPipeline;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
import com.android.systemui.statusbar.phone.NotificationGroupManager;
import com.android.systemui.statusbar.phone.NotificationShadeWindowController;
import com.android.systemui.statusbar.phone.ShadeController;
@@ -106,7 +115,7 @@ import javax.inject.Singleton;
* The controller manages addition, removal, and visible state of bubbles on screen.
*/
@Singleton
-public class BubbleController implements ConfigurationController.ConfigurationListener {
+public class BubbleController implements ConfigurationController.ConfigurationListener, Dumpable {
private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleController" : TAG_BUBBLES;
@@ -130,6 +139,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
private final Context mContext;
private final NotificationEntryManager mNotificationEntryManager;
+ private final NotifPipeline mNotifPipeline;
private final BubbleTaskStackListener mTaskStackListener;
private BubbleStateChangeListener mStateChangeListener;
private BubbleExpandListener mExpandListener;
@@ -220,16 +230,17 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
*/
public interface NotifCallback {
/**
- * Called when the BubbleController wants to remove an entry that it was previously hiding
- * from the shade. See {@link BubbleController#isBubbleNotificationSuppressedFromShade}.
+ * Called when a bubbled notification that was hidden from the shade is now being removed
+ * This can happen when an app cancels a bubbled notification or when the user dismisses a
+ * bubble.
*/
- void removeNotification(NotificationEntry entry);
+ void removeNotification(NotificationEntry entry, int reason);
/**
* Called when a bubbled notification has changed whether it should be
* filtered from the shade.
*/
- void invalidateNotificationFilter(String reason);
+ void invalidateNotifications(String reason);
/**
* Called on a bubbled entry that has been removed when there are no longer
@@ -277,10 +288,14 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
ZenModeController zenModeController,
NotificationLockscreenUserManager notifUserManager,
NotificationGroupManager groupManager,
- NotificationEntryManager entryManager) {
+ NotificationEntryManager entryManager,
+ NotifPipeline notifPipeline,
+ FeatureFlags featureFlags,
+ DumpController dumpController) {
this(context, notificationShadeWindowController, statusBarStateController, shadeController,
data, null /* synchronizer */, configurationController, interruptionStateProvider,
- zenModeController, notifUserManager, groupManager, entryManager);
+ zenModeController, notifUserManager, groupManager, entryManager,
+ notifPipeline, featureFlags, dumpController);
}
public BubbleController(Context context,
@@ -294,7 +309,11 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
ZenModeController zenModeController,
NotificationLockscreenUserManager notifUserManager,
NotificationGroupManager groupManager,
- NotificationEntryManager entryManager) {
+ NotificationEntryManager entryManager,
+ NotifPipeline notifPipeline,
+ FeatureFlags featureFlags,
+ DumpController dumpController) {
+ dumpController.registerDumpable(TAG, this);
mContext = context;
mShadeController = shadeController;
mNotificationInterruptionStateProvider = interruptionStateProvider;
@@ -337,7 +356,13 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
mNotificationEntryManager = entryManager;
mNotificationGroupManager = groupManager;
- setupNEM();
+ mNotifPipeline = notifPipeline;
+
+ if (!featureFlags.isNewNotifPipelineRenderingEnabled()) {
+ setupNEM();
+ } else {
+ setupNotifPipeline();
+ }
mNotificationShadeWindowController = notificationShadeWindowController;
mStatusBarStateListener = new StatusBarStateListener();
@@ -396,6 +421,14 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
}
@Override
+ public void onEntryRemoved(
+ NotificationEntry entry,
+ @android.annotation.Nullable NotificationVisibility visibility,
+ boolean removedByUser) {
+ BubbleController.this.onEntryRemoved(entry);
+ }
+
+ @Override
public void onNotificationRankingUpdated(RankingMap rankingMap) {
onRankingUpdated(rankingMap);
}
@@ -405,8 +438,29 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
new NotificationRemoveInterceptor() {
@Override
public boolean onNotificationRemoveRequested(
- String key, NotificationEntry entry, int reason) {
- return shouldInterceptDismissal(entry, reason);
+ String key,
+ NotificationEntry entry,
+ int dismissReason) {
+ final boolean isClearAll = dismissReason == REASON_CANCEL_ALL;
+ final boolean isUserDimiss = dismissReason == REASON_CANCEL
+ || dismissReason == REASON_CLICK;
+ final boolean isAppCancel = dismissReason == REASON_APP_CANCEL
+ || dismissReason == REASON_APP_CANCEL_ALL;
+ final boolean isSummaryCancel =
+ dismissReason == REASON_GROUP_SUMMARY_CANCELED;
+
+ // Need to check for !appCancel here because the notification may have
+ // previously been dismissed & entry.isRowDismissed would still be true
+ boolean userRemovedNotif =
+ (entry != null && entry.isRowDismissed() && !isAppCancel)
+ || isClearAll || isUserDimiss || isSummaryCancel;
+
+ if (userRemovedNotif || isUserCreatedBubble(key)
+ || isSummaryOfUserCreatedBubble(entry)) {
+ return handleDismissalInterception(entry);
+ }
+
+ return false;
}
});
@@ -430,13 +484,13 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
addNotifCallback(new NotifCallback() {
@Override
- public void removeNotification(NotificationEntry entry) {
+ public void removeNotification(NotificationEntry entry, int reason) {
mNotificationEntryManager.performRemoveNotification(entry.getSbn(),
- UNDEFINED_DISMISS_REASON);
+ reason);
}
@Override
- public void invalidateNotificationFilter(String reason) {
+ public void invalidateNotifications(String reason) {
mNotificationEntryManager.updateNotifications(reason);
}
@@ -444,18 +498,28 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
public void maybeCancelSummary(NotificationEntry entry) {
// Check if removed bubble has an associated suppressed group summary that needs
// to be removed now.
- final String groupKey = entry.getSbn().getGroup();
+ final String groupKey = entry.getSbn().getGroupKey();
if (mBubbleData.isSummarySuppressed(groupKey)) {
- mBubbleData.removeSuppressedSummary(entry.getSbn().getGroupKey());
+ mBubbleData.removeSuppressedSummary(groupKey);
final NotificationEntry summary =
mNotificationEntryManager.getActiveNotificationUnfiltered(
mBubbleData.getSummaryKey(groupKey));
- mNotificationEntryManager.performRemoveNotification(summary.getSbn(),
- UNDEFINED_DISMISS_REASON);
+ if (summary != null) {
+ mNotificationEntryManager.performRemoveNotification(summary.getSbn(),
+ UNDEFINED_DISMISS_REASON);
+ }
}
- // Check if summary should be removed from NoManGroup
+ // Check if we still need to remove the summary from NoManGroup because the summary
+ // may not be in the mBubbleData.mSuppressedGroupKeys list and removed above.
+ // For example:
+ // 1. Bubbled notifications (group) is posted to shade and are visible bubbles
+ // 2. User expands bubbles so now their respective notifications in the shade are
+ // hidden, including the group summary
+ // 3. User removes all bubbles
+ // 4. We expect all the removed bubbles AND the summary (note: the summary was
+ // never added to the suppressedSummary list in BubbleData, so we add this check)
NotificationEntry summary =
mNotificationGroupManager.getLogicalGroupSummary(entry.getSbn());
if (summary != null) {
@@ -472,6 +536,31 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
});
}
+ private void setupNotifPipeline() {
+ mNotifPipeline.addCollectionListener(new NotifCollectionListener() {
+ @Override
+ public void onEntryAdded(NotificationEntry entry) {
+ BubbleController.this.onEntryAdded(entry);
+ }
+
+ @Override
+ public void onEntryUpdated(NotificationEntry entry) {
+ BubbleController.this.onEntryUpdated(entry);
+ }
+
+ @Override
+ public void onRankingUpdate(RankingMap rankingMap) {
+ onRankingUpdated(rankingMap);
+ }
+
+ @Override
+ public void onEntryRemoved(NotificationEntry entry,
+ @NotifCollection.CancellationReason int reason) {
+ BubbleController.this.onEntryRemoved(entry);
+ }
+ });
+ }
+
/**
* Sets whether to perform inflation on the same thread as the caller. This method should only
* be used in tests, not in production.
@@ -752,7 +841,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
Log.d(TAG, "onUserDemotedBubble: " + entry.getKey());
}
entry.setFlagBubble(false);
- removeBubble(entry.getKey(), DISMISS_BLOCKED);
+ removeBubble(entry, DISMISS_BLOCKED);
mUserCreatedBubbles.remove(entry.getKey());
if (BubbleExperimentConfig.isPackageWhitelistedToAutoBubble(
mContext, entry.getSbn().getPackageName())) {
@@ -769,17 +858,29 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
return mUserCreatedBubbles.contains(key);
}
+ boolean isSummaryOfUserCreatedBubble(NotificationEntry entry) {
+ if (isSummaryOfBubbles(entry)) {
+ List<Bubble> bubbleChildren =
+ mBubbleData.getBubblesInGroup(entry.getSbn().getGroupKey());
+ for (int i = 0; i < bubbleChildren.size(); i++) {
+ // Check if any are user-created (i.e. experimental bubbles)
+ if (isUserCreatedBubble(bubbleChildren.get(i).getKey())) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
/**
- * Removes the bubble associated with the {@param uri}.
+ * Removes the bubble with the given NotificationEntry.
* <p>
* Must be called from the main thread.
*/
@MainThread
- void removeBubble(String key, int reason) {
- // TEMP: refactor to change this to pass entry
- Bubble bubble = mBubbleData.getBubbleWithKey(key);
- if (bubble != null) {
- mBubbleData.notificationEntryRemoved(bubble.getEntry(), reason);
+ void removeBubble(NotificationEntry entry, int reason) {
+ if (mBubbleData.hasBubbleWithKey(entry.getKey())) {
+ mBubbleData.notificationEntryRemoved(entry, reason);
}
}
@@ -809,7 +910,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
&& (canLaunchInActivityView(mContext, entry) || wasAdjusted);
if (!shouldBubble && mBubbleData.hasBubbleWithKey(entry.getKey())) {
// It was previously a bubble but no longer a bubble -- lets remove it
- removeBubble(entry.getKey(), DISMISS_NO_LONGER_BUBBLE);
+ removeBubble(entry, DISMISS_NO_LONGER_BUBBLE);
} else if (shouldBubble) {
if (wasAdjusted && !previouslyUserCreated) {
// Gotta treat the auto-bubbled / whitelisted packaged bubbles as usercreated
@@ -819,6 +920,21 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
}
}
+ private void onEntryRemoved(NotificationEntry entry) {
+ if (isSummaryOfBubbles(entry)) {
+ final String groupKey = entry.getSbn().getGroupKey();
+ mBubbleData.removeSuppressedSummary(groupKey);
+
+ // Remove any associated bubble children with the summary
+ final List<Bubble> bubbleChildren = mBubbleData.getBubblesInGroup(groupKey);
+ for (int i = 0; i < bubbleChildren.size(); i++) {
+ removeBubble(bubbleChildren.get(i).getEntry(), DISMISS_GROUP_CANCELLED);
+ }
+ } else {
+ removeBubble(entry, DISMISS_NOTIF_CANCEL);
+ }
+ }
+
private void onRankingUpdated(RankingMap rankingMap) {
// Forward to BubbleData to block any bubbles which should no longer be shown
mBubbleData.notificationRankingUpdated(rankingMap);
@@ -846,7 +962,6 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
final Bubble bubble = removed.first;
@DismissReason final int reason = removed.second;
mStackView.removeBubble(bubble);
-
// If the bubble is removed for user switching, leave the notification in place.
if (reason != DISMISS_USER_CHANGED) {
if (!mBubbleData.hasBubbleWithKey(bubble.getKey())
@@ -854,7 +969,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
// The bubble is now gone & the notification is hidden from the shade, so
// time to actually remove it
for (NotifCallback cb : mCallbacks) {
- cb.removeNotification(bubble.getEntry());
+ cb.removeNotification(bubble.getEntry(), REASON_CANCEL);
}
} else {
// Update the flag for SysUI
@@ -908,7 +1023,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
}
for (NotifCallback cb : mCallbacks) {
- cb.invalidateNotificationFilter("BubbleData.Listener.applyUpdate");
+ cb.invalidateNotifications("BubbleData.Listener.applyUpdate");
}
updateStack();
@@ -930,124 +1045,85 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
};
/**
- * We intercept notification entries cancelled by the user (i.e. dismissed) when there is an
- * active bubble associated with it. We do this so that developers can still cancel it
- * (and hence the bubbles associated with it). However, these intercepted notifications
- * should then be hidden from the shade since the user has cancelled them, so we update
- * {@link Bubble#showInShade}.
- *
- * The cancellation of summaries with children associated with bubbles are also handled in this
- * method. User-cancelled summaries are tracked by {@link BubbleData#addSummaryToSuppress}.
+ * We intercept notification entries (including group summaries) dismissed by the user when
+ * there is an active bubble associated with it. We do this so that developers can still
+ * cancel it (and hence the bubbles associated with it). However, these intercepted
+ * notifications should then be hidden from the shade since the user has cancelled them, so we
+ * {@link Bubble#setSuppressNotification}. For the case of suppressed summaries, we also add
+ * {@link BubbleData#addSummaryToSuppress}.
*
* @return true if we want to intercept the dismissal of the entry, else false.
*/
- public boolean shouldInterceptDismissal(NotificationEntry entry, int dismissReason) {
+ public boolean handleDismissalInterception(NotificationEntry entry) {
if (entry == null) {
return false;
}
- String key = entry.getKey();
- String groupKey = entry != null ? entry.getSbn().getGroupKey() : null;
- ArrayList<Bubble> bubbleChildren = mBubbleData.getBubblesInGroup(groupKey);
- boolean inBubbleData = mBubbleData.hasBubbleWithKey(key);
- boolean isSuppressedSummary = (mBubbleData.isSummarySuppressed(groupKey)
- && mBubbleData.getSummaryKey(groupKey).equals(key));
- boolean isSummary = entry != null
- && entry.getSbn().getNotification().isGroupSummary();
- boolean isSummaryOfBubbles = (isSuppressedSummary || isSummary)
- && bubbleChildren != null && !bubbleChildren.isEmpty();
+ final boolean interceptBubbleDismissal = mBubbleData.hasBubbleWithKey(entry.getKey())
+ && entry.isBubble();
+ final boolean interceptSummaryDismissal = isSummaryOfBubbles(entry);
- if (!inBubbleData && !isSummaryOfBubbles) {
+ if (interceptSummaryDismissal) {
+ handleSummaryDismissalInterception(entry);
+ } else if (interceptBubbleDismissal) {
+ Bubble bubble = mBubbleData.getBubbleWithKey(entry.getKey());
+ bubble.setSuppressNotification(true);
+ bubble.setShowDot(false /* show */, true /* animate */);
+ } else {
return false;
}
- final boolean isClearAll = dismissReason == REASON_CANCEL_ALL;
- final boolean isUserDimiss = dismissReason == REASON_CANCEL
- || dismissReason == REASON_CLICK;
- final boolean isAppCancel = dismissReason == REASON_APP_CANCEL
- || dismissReason == REASON_APP_CANCEL_ALL;
- final boolean isSummaryCancel = dismissReason == REASON_GROUP_SUMMARY_CANCELED;
-
- // Need to check for !appCancel here because the notification may have
- // previously been dismissed & entry.isRowDismissed would still be true
- boolean userRemovedNotif = (entry != null && entry.isRowDismissed() && !isAppCancel)
- || isClearAll || isUserDimiss || isSummaryCancel;
- if (isSummaryOfBubbles) {
- return handleSummaryRemovalInterception(entry, userRemovedNotif);
+ // Update the shade
+ for (NotifCallback cb : mCallbacks) {
+ cb.invalidateNotifications("BubbleController.handleDismissalInterception");
}
+ return true;
+ }
- // The bubble notification sticks around in the data as long as the bubble is
- // not dismissed and the app hasn't cancelled the notification.
- Bubble bubble = mBubbleData.getBubbleWithKey(key);
- boolean bubbleExtended = entry != null && entry.isBubble()
- && (userRemovedNotif || isUserCreatedBubble(bubble.getKey()));
- if (bubbleExtended) {
- bubble.setSuppressNotification(true);
- bubble.setShowDot(false /* show */, true /* animate */);
- for (NotifCallback cb : mCallbacks) {
- cb.invalidateNotificationFilter("BubbleController"
- + ".shouldInterceptDismissal");
- }
- return true;
- } else if (!userRemovedNotif && entry != null) {
- // This wasn't a user removal so we should remove the bubble as well
- mBubbleData.notificationEntryRemoved(entry, DISMISS_NOTIF_CANCEL);
+ private boolean isSummaryOfBubbles(NotificationEntry entry) {
+ if (entry == null) {
return false;
}
- return false;
- }
- private boolean handleSummaryRemovalInterception(NotificationEntry summary,
- boolean userRemovedNotif) {
- String groupKey = summary.getSbn().getGroupKey();
+ String groupKey = entry.getSbn().getGroupKey();
ArrayList<Bubble> bubbleChildren = mBubbleData.getBubblesInGroup(groupKey);
+ boolean isSuppressedSummary = (mBubbleData.isSummarySuppressed(groupKey)
+ && mBubbleData.getSummaryKey(groupKey).equals(entry.getKey()));
+ boolean isSummary = entry.getSbn().getNotification().isGroupSummary();
+ return (isSuppressedSummary || isSummary)
+ && bubbleChildren != null
+ && !bubbleChildren.isEmpty();
+ }
- if (userRemovedNotif) {
- // If it's a user dismiss we mark the children to be hidden from the shade.
- for (int i = 0; i < bubbleChildren.size(); i++) {
- Bubble bubbleChild = bubbleChildren.get(i);
- // As far as group manager is concerned, once a child is no longer shown
- // in the shade, it is essentially removed.
- mNotificationGroupManager.onEntryRemoved(bubbleChild.getEntry());
- bubbleChild.setSuppressNotification(true);
- bubbleChild.setShowDot(false /* show */, true /* animate */);
- }
- // And since all children are removed, remove the summary.
- mNotificationGroupManager.onEntryRemoved(summary);
-
- // If the summary was auto-generated we don't need to keep that notification around
- // because apps can't cancel it; so we only intercept & suppress real summaries.
- boolean isAutogroupSummary = (summary.getSbn().getNotification().flags
- & FLAG_AUTOGROUP_SUMMARY) != 0;
- if (!isAutogroupSummary) {
- // TODO: (b/145659174) remove references to mSuppressedGroupKeys once fully migrated
- mBubbleData.addSummaryToSuppress(summary.getSbn().getGroupKey(),
- summary.getKey());
- // Tell shade to update for the suppression
- mNotificationEntryManager.updateNotifications("BubbleController"
- + ".handleSummaryRemovalInterception");
- }
- return !isAutogroupSummary;
- } else {
- // If it's not a user dismiss it's a cancel.
- for (int i = 0; i < bubbleChildren.size(); i++) {
- // First check if any of these are user-created (i.e. experimental bubbles)
- if (mUserCreatedBubbles.contains(bubbleChildren.get(i).getKey())) {
- // Experimental bubble! Intercept the removal.
- return true;
+ private void handleSummaryDismissalInterception(NotificationEntry summary) {
+ // current children in the row:
+ final List<NotificationEntry> children = summary.getChildren();
+ if (children != null) {
+ for (int i = 0; i < children.size(); i++) {
+ NotificationEntry child = children.get(i);
+ if (mBubbleData.hasBubbleWithKey(child.getKey())) {
+ // Suppress the bubbled child
+ // As far as group manager is concerned, once a child is no longer shown
+ // in the shade, it is essentially removed.
+ Bubble bubbleChild = mBubbleData.getBubbleWithKey(child.getKey());
+ mNotificationGroupManager.onEntryRemoved(bubbleChild.getEntry());
+ bubbleChild.setSuppressNotification(true);
+ bubbleChild.setShowDot(false /* show */, true /* animate */);
+ } else {
+ // non-bubbled children can be removed
+ for (NotifCallback cb : mCallbacks) {
+ cb.removeNotification(child, REASON_GROUP_SUMMARY_CANCELED);
+ }
}
}
-
- // Not an experimental bubble, safe to remove.
- mBubbleData.removeSuppressedSummary(groupKey);
- // Remove any associated bubble children with the summary.
- for (int i = 0; i < bubbleChildren.size(); i++) {
- Bubble bubbleChild = bubbleChildren.get(i);
- mBubbleData.notificationEntryRemoved(bubbleChild.getEntry(),
- DISMISS_GROUP_CANCELLED);
- }
- return false;
}
+
+ // And since all children are removed, remove the summary.
+ mNotificationGroupManager.onEntryRemoved(summary);
+
+ // TODO: (b/145659174) remove references to mSuppressedGroupKeys once fully migrated
+ mBubbleData.addSummaryToSuppress(summary.getSbn().getGroupKey(),
+ summary.getKey());
}
/**
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
index 50a50633f43c..0d5261dcb7f3 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
@@ -19,9 +19,9 @@ package com.android.systemui.bubbles;
import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
import static android.view.Display.INVALID_DISPLAY;
-
import static android.view.ViewRootImpl.NEW_INSETS_MODE_FULL;
import static android.view.ViewRootImpl.sNewInsetsMode;
+
import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_EXPANDED_VIEW;
import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
@@ -56,6 +56,7 @@ import com.android.systemui.R;
import com.android.systemui.recents.TriangleShape;
import com.android.systemui.shared.system.SysUiStatsLog;
import com.android.systemui.statusbar.AlphaOptimizedButton;
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
/**
* Container for the expanded bubble view, handles rendering the caret and settings icon.
@@ -146,7 +147,7 @@ public class BubbleExpandedView extends LinearLayout implements View.OnClickList
// the bubble again so we'll just remove it.
Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
+ ", " + e.getMessage() + "; removing bubble");
- mBubbleController.removeBubble(getBubbleKey(),
+ mBubbleController.removeBubble(getBubbleEntry(),
BubbleController.DISMISS_INVALID_INTENT);
}
});
@@ -190,7 +191,7 @@ public class BubbleExpandedView extends LinearLayout implements View.OnClickList
}
if (mBubble != null && !mBubbleController.isUserCreatedBubble(mBubble.getKey())) {
// Must post because this is called from a binder thread.
- post(() -> mBubbleController.removeBubble(mBubble.getKey(),
+ post(() -> mBubbleController.removeBubble(mBubble.getEntry(),
BubbleController.DISMISS_TASK_FINISHED));
}
}
@@ -279,6 +280,10 @@ public class BubbleExpandedView extends LinearLayout implements View.OnClickList
return mBubble != null ? mBubble.getKey() : "null";
}
+ private NotificationEntry getBubbleEntry() {
+ return mBubble != null ? mBubble.getEntry() : null;
+ }
+
void applyThemeAttrs() {
final TypedArray ta = mContext.obtainStyledAttributes(
new int[] {
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
index 34d3c24b4f48..645696d0bcac 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
@@ -198,9 +198,14 @@ class BubbleTouchHandler implements View.OnTouchListener {
if (isStack) {
mController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
} else {
- mController.removeBubble(
- individualBubbleKey,
- BubbleController.DISMISS_USER_GESTURE);
+ final Bubble bubble =
+ mBubbleData.getBubbleWithKey(individualBubbleKey);
+ // bubble can be null if the user is in the middle of
+ // dismissing the bubble, but the app also sent a cancel
+ if (bubble != null) {
+ mController.removeBubble(bubble.getEntry(),
+ BubbleController.DISMISS_USER_GESTURE);
+ }
}
});
} else if (isFlyout) {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java
index 7fe229c26f3a..3fa1954a7fcc 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java
@@ -123,6 +123,16 @@ public class ListDumper {
.append(" ");
}
+ if (!notifEntry.mDismissInterceptors.isEmpty()) {
+ String[] interceptorsNames = new String[notifEntry.mDismissInterceptors.size()];
+ for (int i = 0; i < interceptorsNames.length; i++) {
+ interceptorsNames[i] = notifEntry.mDismissInterceptors.get(i).getName();
+ }
+ rksb.append("dismissInterceptors=")
+ .append(Arrays.toString(interceptorsNames))
+ .append(" ");
+ }
+
if (notifEntry.mExcludingFilter != null) {
rksb.append("filter=")
.append(notifEntry.mExcludingFilter)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java
index 3b2fe9441c32..38d8d979a4da 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java
@@ -63,6 +63,7 @@ import com.android.systemui.statusbar.notification.collection.notifcollection.Co
import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionLogger;
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender;
import com.android.systemui.util.Assert;
@@ -116,6 +117,7 @@ public class NotifCollection implements Dumpable {
@Nullable private CollectionReadyForBuildListener mBuildListener;
private final List<NotifCollectionListener> mNotifCollectionListeners = new ArrayList<>();
private final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>();
+ private final List<NotifDismissInterceptor> mDismissInterceptors = new ArrayList<>();
private boolean mAttached = false;
private boolean mAmDispatchingToOtherCode;
@@ -176,10 +178,21 @@ public class NotifCollection implements Dumpable {
extender.setCallback(this::onEndLifetimeExtension);
}
+ /** @see NotifPipeline#addNotificationDismissInterceptor(NotifDismissInterceptor) */
+ void addNotificationDismissInterceptor(NotifDismissInterceptor interceptor) {
+ Assert.isMainThread();
+ checkForReentrantCall();
+ if (mDismissInterceptors.contains(interceptor)) {
+ throw new IllegalArgumentException("Interceptor " + interceptor + " already added.");
+ }
+ mDismissInterceptors.add(interceptor);
+ interceptor.setCallback(this::onEndDismissInterception);
+ }
+
/**
* Dismiss a notification on behalf of the user.
*/
- void dismissNotification(NotificationEntry entry, @NonNull DismissedByUserStats stats) {
+ public void dismissNotification(NotificationEntry entry, @NonNull DismissedByUserStats stats) {
Assert.isMainThread();
requireNonNull(stats);
checkForReentrantCall();
@@ -192,6 +205,12 @@ public class NotifCollection implements Dumpable {
return;
}
+ updateDismissInterceptors(entry);
+ if (isDismissIntercepted(entry)) {
+ mLogger.logNotifDismissedIntercepted(entry.getKey());
+ return;
+ }
+
// Optimistically mark the notification as dismissed -- we'll wait for the signal from
// system server before removing it from our notification set.
entry.setDismissState(DISMISSED);
@@ -236,7 +255,6 @@ public class NotifCollection implements Dumpable {
for (NotificationEntry canceledEntry : canceledEntries) {
tryRemoveNotification(canceledEntry);
}
-
rebuildList();
}
@@ -307,11 +325,11 @@ public class NotifCollection implements Dumpable {
// Update to an existing entry
mLogger.logNotifUpdated(sbn.getKey());
+ // Notification is updated so it is essentially re-added and thus alive again, so we
+ // can reset its state.
cancelLocalDismissal(entry);
-
- // Notification is updated so it is essentially re-added and thus alive again. Don't
- // need to keep its lifetime extended.
cancelLifetimeExtension(entry);
+ cancelDismissInterception(entry);
entry.mCancellationReason = REASON_NOT_CANCELED;
entry.setSbn(sbn);
@@ -348,6 +366,7 @@ public class NotifCollection implements Dumpable {
if (!isLifetimeExtended(entry)) {
mNotificationSet.remove(entry.getKey());
+ cancelDismissInterception(entry);
dispatchOnEntryRemoved(entry, entry.mCancellationReason);
dispatchOnEntryCleanUp(entry);
return true;
@@ -436,6 +455,17 @@ public class NotifCollection implements Dumpable {
mAmDispatchingToOtherCode = false;
}
+ private void updateDismissInterceptors(@NonNull NotificationEntry entry) {
+ entry.mDismissInterceptors.clear();
+ mAmDispatchingToOtherCode = true;
+ for (NotifDismissInterceptor interceptor : mDismissInterceptors) {
+ if (interceptor.shouldInterceptDismissal(entry)) {
+ entry.mDismissInterceptors.add(interceptor);
+ }
+ }
+ mAmDispatchingToOtherCode = false;
+ }
+
private void cancelLocalDismissal(NotificationEntry entry) {
if (isDismissedByUser(entry)) {
entry.setDismissState(NOT_DISMISSED);
@@ -450,6 +480,42 @@ public class NotifCollection implements Dumpable {
}
}
+ private void onEndDismissInterception(
+ NotifDismissInterceptor interceptor,
+ NotificationEntry entry,
+ @NonNull DismissedByUserStats stats) {
+ Assert.isMainThread();
+ if (!mAttached) {
+ return;
+ }
+ checkForReentrantCall();
+
+ if (!entry.mDismissInterceptors.remove(interceptor)) {
+ throw new IllegalStateException(
+ String.format(
+ "Cannot end dismiss interceptor for interceptor \"%s\" (%s)",
+ interceptor.getName(),
+ interceptor));
+ }
+
+ if (!isDismissIntercepted(entry)) {
+ dismissNotification(entry, stats);
+ }
+ }
+
+ private void cancelDismissInterception(NotificationEntry entry) {
+ mAmDispatchingToOtherCode = true;
+ for (NotifDismissInterceptor interceptor : entry.mDismissInterceptors) {
+ interceptor.cancelDismissInterception(entry);
+ }
+ mAmDispatchingToOtherCode = false;
+ entry.mDismissInterceptors.clear();
+ }
+
+ private boolean isDismissIntercepted(NotificationEntry entry) {
+ return entry.mDismissInterceptors.size() > 0;
+ }
+
private void checkForReentrantCall() {
if (mAmDispatchingToOtherCode) {
throw new IllegalStateException("Reentrant call detected");
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java
index 5767ad93014e..d4d2369ba822 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifPipeline.java
@@ -25,6 +25,7 @@ import com.android.systemui.statusbar.notification.collection.listbuilder.plugga
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSection;
import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender;
import java.util.Collection;
@@ -97,13 +98,21 @@ public class NotifPipeline implements CommonNotifCollection {
/**
* Registers a lifetime extender. Lifetime extenders can cause notifications that have been
- * dismissed or retracted to be temporarily retained in the collection.
+ * dismissed or retracted by system server to be temporarily retained in the collection.
*/
public void addNotificationLifetimeExtender(NotifLifetimeExtender extender) {
mNotifCollection.addNotificationLifetimeExtender(extender);
}
/**
+ * Registers a dismiss interceptor. Dismiss interceptors can cause notifications that have been
+ * dismissed by the user to be retained (won't send a dismissal to system server).
+ */
+ public void addNotificationDismissInterceptor(NotifDismissInterceptor interceptor) {
+ mNotifCollection.addNotificationDismissInterceptor(interceptor);
+ }
+
+ /**
* Registers a filter with the pipeline before grouping, promoting and sorting occurs. Filters
* are called on each notification in the order that they were registered. If any filter
* returns true, the notification is removed from the pipeline (and no other filters are
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
index a489f3b22031..006d40ddbac5 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
@@ -66,6 +66,7 @@ import com.android.systemui.statusbar.notification.InflationException;
import com.android.systemui.statusbar.notification.collection.NotifCollection.CancellationReason;
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter;
import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter;
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController;
@@ -105,6 +106,9 @@ public final class NotificationEntry extends ListEntry {
/** List of lifetime extenders that are extending the lifetime of this notification. */
final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>();
+ /** List of dismiss interceptors that are intercepting the dismissal of this notification. */
+ final List<NotifDismissInterceptor> mDismissInterceptors = new ArrayList<>();
+
/** If this notification was filtered out, then the filter that did the filtering. */
@Nullable NotifFilter mExcludingFilter;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/BubbleCoordinator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/BubbleCoordinator.java
new file mode 100644
index 000000000000..116c70c4f1cf
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/BubbleCoordinator.java
@@ -0,0 +1,164 @@
+/*
+ * 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 static android.service.notification.NotificationStats.DISMISSAL_OTHER;
+import static android.service.notification.NotificationStats.DISMISS_SENTIMENT_UNKNOWN;
+
+import com.android.internal.statusbar.NotificationVisibility;
+import com.android.systemui.bubbles.BubbleController;
+import com.android.systemui.statusbar.notification.collection.NotifCollection;
+import com.android.systemui.statusbar.notification.collection.NotifPipeline;
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter;
+import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats;
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor;
+import com.android.systemui.statusbar.notification.logging.NotificationLogger;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+/**
+ * Coordinates hiding, intercepting (the dismissal), and deletion of bubbled notifications.
+ *
+ * The typical "start state" for a bubbled notification is when a bubble-able notification is
+ * posted. It is visible as a bubble AND as a notification in the shade. From here, we can get
+ * into a few hidden-from-shade states described below:
+ *
+ * Start State -> Hidden from shade
+ * User expands the bubble so we hide its notification from the shade.
+ * OR
+ * User dismisses a group summary with a bubbled child. All bubbled children are now hidden from
+ * the shade. And the group summary's dismissal is intercepted + hidden from the shade (see below).
+ *
+ * Start State -> Dismissal intercepted + hidden from shade
+ * User dismisses the notification from the shade. We now hide the notification from the shade
+ * and intercept its dismissal (the removal signal is never sent to system server). We
+ * keep the notification alive in system server so that {@link BubbleController} can still
+ * respond to app-cancellations (ie: remove the bubble if the app cancels the notification).
+ *
+ */
+@Singleton
+public class BubbleCoordinator implements Coordinator {
+ private static final String TAG = "BubbleCoordinator";
+
+ private final BubbleController mBubbleController;
+ private final NotifCollection mNotifCollection;
+ private final Set<String> mInterceptedDismissalEntries = new HashSet<>();
+ private NotifPipeline mNotifPipeline;
+ private NotifDismissInterceptor.OnEndDismissInterception mOnEndDismissInterception;
+
+ @Inject
+ public BubbleCoordinator(
+ BubbleController bubbleController,
+ NotifCollection notifCollection) {
+ mBubbleController = bubbleController;
+ mNotifCollection = notifCollection;
+ }
+
+ @Override
+ public void attach(NotifPipeline pipeline) {
+ mNotifPipeline = pipeline;
+ mNotifPipeline.addNotificationDismissInterceptor(mDismissInterceptor);
+ mNotifPipeline.addPreRenderFilter(mNotifFilter);
+ mBubbleController.addNotifCallback(mNotifCallback);
+ }
+
+ private final NotifFilter mNotifFilter = new NotifFilter(TAG) {
+ @Override
+ public boolean shouldFilterOut(NotificationEntry entry, long now) {
+ return mBubbleController.isBubbleNotificationSuppressedFromShade(entry);
+ }
+ };
+
+ private final NotifDismissInterceptor mDismissInterceptor = new NotifDismissInterceptor() {
+ @Override
+ public String getName() {
+ return TAG;
+ }
+
+ @Override
+ public void setCallback(OnEndDismissInterception callback) {
+ mOnEndDismissInterception = callback;
+ }
+
+ @Override
+ public boolean shouldInterceptDismissal(NotificationEntry entry) {
+ // TODO: b/149041810 add support for intercepting app-cancelled bubble notifications
+ // for experimental bubbles
+ if (mBubbleController.handleDismissalInterception(entry)) {
+ mInterceptedDismissalEntries.add(entry.getKey());
+ return true;
+ } else {
+ mInterceptedDismissalEntries.remove(entry.getKey());
+ return false;
+ }
+ }
+
+ @Override
+ public void cancelDismissInterception(NotificationEntry entry) {
+ mInterceptedDismissalEntries.remove(entry.getKey());
+ }
+ };
+
+ private final BubbleController.NotifCallback mNotifCallback =
+ new BubbleController.NotifCallback() {
+ @Override
+ public void removeNotification(NotificationEntry entry, int reason) {
+ if (isInterceptingDismissal(entry)) {
+ mInterceptedDismissalEntries.remove(entry.getKey());
+ mOnEndDismissInterception.onEndDismissInterception(mDismissInterceptor, entry,
+ createDismissedByUserStats(entry));
+ } else if (mNotifPipeline.getActiveNotifs().contains(entry)) {
+ // Bubbles are hiding the notifications from the shade, but the bubble was
+ // deleted; therefore, the notification should be cancelled as if it were a user
+ // dismissal (this won't re-enter handleInterceptDimissal because Bubbles
+ // will have already marked it as no longer a bubble)
+ mNotifCollection.dismissNotification(entry, createDismissedByUserStats(entry));
+ }
+ }
+
+ @Override
+ public void invalidateNotifications(String reason) {
+ mNotifFilter.invalidateList();
+ }
+
+ @Override
+ public void maybeCancelSummary(NotificationEntry entry) {
+ // no-op
+ }
+ };
+
+ private boolean isInterceptingDismissal(NotificationEntry entry) {
+ return mInterceptedDismissalEntries.contains(entry.getKey());
+ }
+
+ private DismissedByUserStats createDismissedByUserStats(NotificationEntry entry) {
+ return new DismissedByUserStats(
+ DISMISSAL_OTHER,
+ DISMISS_SENTIMENT_UNKNOWN,
+ NotificationVisibility.obtain(entry.getKey(),
+ entry.getRanking().getRank(),
+ mNotifPipeline.getActiveNotifs().size(),
+ true, // was visible as a bubble
+ NotificationLogger.getNotificationLocation(entry))
+ );
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java
index 0a1e09f4c99d..7a9547c573bb 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/NotifCoordinators.java
@@ -53,6 +53,7 @@ public class NotifCoordinators implements Dumpable {
RankingCoordinator rankingCoordinator,
ForegroundCoordinator foregroundCoordinator,
DeviceProvisionedCoordinator deviceProvisionedCoordinator,
+ BubbleCoordinator bubbleCoordinator,
PreparationCoordinator preparationCoordinator) {
dumpController.registerDumpable(TAG, this);
@@ -61,6 +62,7 @@ public class NotifCoordinators implements Dumpable {
mCoordinators.add(rankingCoordinator);
mCoordinators.add(foregroundCoordinator);
mCoordinators.add(deviceProvisionedCoordinator);
+ mCoordinators.add(bubbleCoordinator);
if (featureFlags.isNewNotifPipelineRenderingEnabled()) {
mCoordinators.add(preparationCoordinator);
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt
index 14e15031056f..dc7a50d621a1 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifCollectionLogger.kt
@@ -69,6 +69,14 @@ class NotifCollectionLogger @Inject constructor(
})
}
+ fun logNotifDismissedIntercepted(key: String) {
+ buffer.log(TAG, INFO, {
+ str1 = key
+ }, {
+ "DISMISS INTERCEPTED $str1"
+ })
+ }
+
fun logRankingMissing(key: String, rankingMap: RankingMap) {
buffer.log(TAG, WARNING, { str1 = key }, { "Ranking update is missing ranking for $str1" })
buffer.log(TAG, DEBUG, {}, { "Ranking map contents:" })
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifDismissInterceptor.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifDismissInterceptor.java
new file mode 100644
index 000000000000..3354ad1daf20
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/notifcollection/NotifDismissInterceptor.java
@@ -0,0 +1,68 @@
+/*
+ * 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.notifcollection;
+
+import com.android.systemui.statusbar.notification.collection.NotifCollection;
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+
+/**
+ * A way for coordinators to temporarily intercept a user-dismissed notification before a message
+ * is sent to system server to officially remove this notification.
+ * See {@link NotifCollection#addNotificationDismissInterceptor(NotifDismissInterceptor)}.
+ */
+public interface NotifDismissInterceptor {
+ /** Name to associate with this interceptor (for the purposes of debugging) */
+ String getName();
+
+ /**
+ * Called on the interceptor immediately after it has been registered. The interceptor should
+ * hang on to this callback and execute it whenever it no longer needs to intercept the
+ * dismissal of the notification.
+ */
+ void setCallback(OnEndDismissInterception callback);
+
+ /**
+ * Called by the NotifCollection whenever a notification has been dismissed (by the user).
+ * If the interceptor returns true, it is considered to be intercepting the notification.
+ * Intercepted notifications will not be sent to system server for removal until it is no
+ * longer being intercepted. However, the notification can still be cancelled by the app.
+ * This method is called on all interceptors even if earlier ones return true.
+ */
+ boolean shouldInterceptDismissal(NotificationEntry entry);
+
+
+ /**
+ * Called by the NotifCollection to inform a DismissInterceptor that its interception of a notif
+ * is no longer valid (usually because the notif has been removed by means other than the
+ * user dismissing the notification from the shade, or the notification has been updated). The
+ * interceptor should clean up any references it has to the notif in question.
+ */
+ void cancelDismissInterception(NotificationEntry entry);
+
+ /**
+ * Callback for notifying the NotifCollection that it no longer is intercepting the dismissal.
+ * If the end of this dismiss interception triggers a dismiss (ie: no other
+ * NotifDismissInterceptors are intercepting the entry), NotifCollection will use stats
+ * in the message sent to system server for the notification's dismissal.
+ */
+ interface OnEndDismissInterception {
+ void onEndDismissInterception(
+ NotifDismissInterceptor interceptor,
+ NotificationEntry entry,
+ DismissedByUserStats stats);
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
index 11f70796748c..c68d9942419b 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
@@ -2466,10 +2466,6 @@ public class StatusBar extends SystemUI implements DemoMode,
pw.println(" mHeadsUpManager: null");
}
- if (mBubbleController != null) {
- mBubbleController.dump(fd, pw, args);
- }
-
if (mLightBarController != null) {
mLightBarController.dump(fd, pw, args);
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
index 280d14e42c5f..c3b55e2ec168 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
@@ -20,8 +20,7 @@ import static android.app.Notification.FLAG_BUBBLE;
import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CANCEL_ALL;
-
-import static com.android.systemui.statusbar.notification.NotificationEntryManager.UNDEFINED_DISMISS_REASON;
+import static android.service.notification.NotificationListenerService.REASON_GROUP_SUMMARY_CANCELED;
import static com.google.common.truth.Truth.assertThat;
@@ -34,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -55,10 +55,12 @@ import android.view.WindowManager;
import androidx.test.filters.SmallTest;
import com.android.internal.colorextraction.ColorExtractor;
+import com.android.systemui.DumpController;
import com.android.systemui.SystemUIFactory;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.colorextraction.SysuiColorExtractor;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
+import com.android.systemui.statusbar.FeatureFlags;
import com.android.systemui.statusbar.NotificationLockscreenUserManager;
import com.android.systemui.statusbar.NotificationPresenter;
import com.android.systemui.statusbar.NotificationRemoveInterceptor;
@@ -68,6 +70,7 @@ import com.android.systemui.statusbar.notification.NotificationEntryListener;
import com.android.systemui.statusbar.notification.NotificationEntryManager;
import com.android.systemui.statusbar.notification.NotificationFilter;
import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider;
+import com.android.systemui.statusbar.notification.collection.NotifPipeline;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.row.ActivatableNotificationView;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
@@ -92,6 +95,13 @@ import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import java.util.List;
+
+/**
+ * Tests the NotificationEntryManager setup with BubbleController.
+ * The {@link NotifPipeline} setup with BubbleController is tested in
+ * {@link NewNotifPipelineBubbleControllerTest}.
+ */
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
@@ -152,6 +162,12 @@ public class BubbleControllerTest extends SysuiTestCase {
private ShadeController mShadeController;
@Mock
private NotificationRowComponent mNotificationRowComponent;
+ @Mock
+ private NotifPipeline mNotifPipeline;
+ @Mock
+ private FeatureFlags mFeatureFlagsOldPipeline;
+ @Mock
+ private DumpController mDumpController;
private SuperStatusBarViewFactory mSuperStatusBarViewFactory;
private BubbleData mBubbleData;
@@ -213,6 +229,7 @@ public class BubbleControllerTest extends SysuiTestCase {
mock(HeadsUpManager.class),
mock(NotificationInterruptionStateProvider.HeadsUpSuppressor.class));
mBubbleData = new BubbleData(mContext);
+ when(mFeatureFlagsOldPipeline.isNewNotifPipelineRenderingEnabled()).thenReturn(false);
mBubbleController = new TestableBubbleController(mContext,
mNotificationShadeWindowController,
mStatusBarStateController,
@@ -223,7 +240,10 @@ public class BubbleControllerTest extends SysuiTestCase {
mZenModeController,
mLockscreenUserManager,
mNotificationGroupManager,
- mNotificationEntryManager);
+ mNotificationEntryManager,
+ mNotifPipeline,
+ mFeatureFlagsOldPipeline,
+ mDumpController);
mBubbleController.setBubbleStateChangeListener(mBubbleStateChangeListener);
mBubbleController.setExpandListener(mBubbleExpandListener);
@@ -261,7 +281,7 @@ public class BubbleControllerTest extends SysuiTestCase {
verify(mBubbleStateChangeListener).onHasBubblesChanged(true);
mBubbleController.removeBubble(
- mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE);
+ mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
assertFalse(mNotificationShadeWindowController.getBubblesShowing());
assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
verify(mNotificationEntryManager, times(2)).updateNotifications(anyString());
@@ -282,12 +302,12 @@ public class BubbleControllerTest extends SysuiTestCase {
// Now remove the bubble
mBubbleController.removeBubble(
- mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE);
+ mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
// Since the notif is dismissed, once the bubble is removed, performRemoveNotification gets
// called to really remove the notif
verify(mNotificationEntryManager, times(1)).performRemoveNotification(
- mRow.getEntry().getSbn(), UNDEFINED_DISMISS_REASON);
+ eq(mRow.getEntry().getSbn()), anyInt());
assertFalse(mBubbleController.hasBubbles());
}
@@ -467,7 +487,7 @@ public class BubbleControllerTest extends SysuiTestCase {
mRow2.getEntry()));
// Dismiss currently expanded
- mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
+ mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(),
BubbleController.DISMISS_USER_GESTURE);
verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey());
@@ -476,7 +496,7 @@ public class BubbleControllerTest extends SysuiTestCase {
verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
// Dismiss that one
- mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
+ mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(),
BubbleController.DISMISS_USER_GESTURE);
// Make sure state changes and collapse happens
@@ -604,7 +624,7 @@ public class BubbleControllerTest extends SysuiTestCase {
@Test
public void testDeleteIntent_removeBubble_aged() throws PendingIntent.CanceledException {
mBubbleController.updateBubble(mRow.getEntry());
- mBubbleController.removeBubble(mRow.getEntry().getKey(), BubbleController.DISMISS_AGED);
+ mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_AGED);
verify(mDeleteIntent, never()).send();
}
@@ -612,7 +632,7 @@ public class BubbleControllerTest extends SysuiTestCase {
public void testDeleteIntent_removeBubble_user() throws PendingIntent.CanceledException {
mBubbleController.updateBubble(mRow.getEntry());
mBubbleController.removeBubble(
- mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE);
+ mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
verify(mDeleteIntent, times(1)).send();
}
@@ -649,11 +669,22 @@ public class BubbleControllerTest extends SysuiTestCase {
// Cancels always remove so no need to intercept
assertFalse(intercepted);
+ }
+
+ @Test
+ public void testRemoveBubble_entryListenerRemove() {
+ mEntryListener.onPendingEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ assertTrue(mBubbleController.hasBubbles());
+
+ // Removes the notification
+ mEntryListener.onEntryRemoved(mRow.getEntry(), null, false);
assertFalse(mBubbleController.hasBubbles());
}
@Test
- public void removeBubble_fails_clearAll() {
+ public void removeBubble_clearAllIntercepted() {
mEntryListener.onPendingEntryAdded(mRow.getEntry());
mBubbleController.updateBubble(mRow.getEntry());
@@ -669,14 +700,10 @@ public class BubbleControllerTest extends SysuiTestCase {
// Should update show in shade state
assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
mRow.getEntry()));
-
- verify(mNotificationEntryManager, never()).performRemoveNotification(
- any(), anyInt());
- assertTrue(mBubbleController.hasBubbles());
}
@Test
- public void removeBubble_fails_userDismissNotif() {
+ public void removeBubble_userDismissNotifIntercepted() {
mEntryListener.onPendingEntryAdded(mRow.getEntry());
mBubbleController.updateBubble(mRow.getEntry());
@@ -692,10 +719,6 @@ public class BubbleControllerTest extends SysuiTestCase {
// Should update show in shade state
assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
mRow.getEntry()));
-
- verify(mNotificationEntryManager, never()).performRemoveNotification(
- any(), anyInt());
- assertTrue(mBubbleController.hasBubbles());
}
@Test
@@ -709,7 +732,7 @@ public class BubbleControllerTest extends SysuiTestCase {
// Dismiss the bubble
mBubbleController.removeBubble(
- mRow.getEntry().getKey(), BubbleController.DISMISS_USER_GESTURE);
+ mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
assertFalse(mBubbleController.hasBubbles());
// Dismiss the notification
@@ -767,6 +790,74 @@ public class BubbleControllerTest extends SysuiTestCase {
mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
}
+ @Test
+ public void testBubbleSummaryDismissal_suppressesSummaryAndBubbleFromShade() throws Exception {
+ // GIVEN a group summary with a bubble child
+ ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0);
+ ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
+ mEntryListener.onPendingEntryAdded(groupedBubble.getEntry());
+ groupSummary.addChildNotification(groupedBubble);
+ assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+
+ // WHEN the summary is dismissed
+ mBubbleController.handleDismissalInterception(groupSummary.getEntry());
+
+ // THEN the summary and bubbled child are suppressed from the shade
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ groupedBubble.getEntry()));
+ assertTrue(mBubbleData.isSummarySuppressed(groupSummary.getEntry().getSbn().getGroupKey()));
+ }
+
+ @Test
+ public void testAppRemovesSummary_removesAllBubbleChildren() throws Exception {
+ // GIVEN a group summary with a bubble child
+ ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0);
+ ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
+ mEntryListener.onPendingEntryAdded(groupedBubble.getEntry());
+ groupSummary.addChildNotification(groupedBubble);
+ assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+
+ // GIVEN the summary is dismissed
+ mBubbleController.handleDismissalInterception(groupSummary.getEntry());
+
+ // WHEN the summary is cancelled by the app
+ mEntryListener.onEntryRemoved(groupSummary.getEntry(), null, true);
+
+ // THEN the summary and its children are removed from bubble data
+ assertFalse(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+ assertFalse(mBubbleData.isSummarySuppressed(
+ groupSummary.getEntry().getSbn().getGroupKey()));
+ }
+
+ @Test
+ public void testSummaryDismissal_marksBubblesHiddenFromShadeAndDismissesNonBubbledChildren()
+ throws Exception {
+ // GIVEN a group summary with two (non-bubble) children and one bubble child
+ ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(2);
+ ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
+ mEntryListener.onPendingEntryAdded(groupedBubble.getEntry());
+ groupSummary.addChildNotification(groupedBubble);
+
+ // WHEN the summary is dismissed
+ mBubbleController.handleDismissalInterception(groupSummary.getEntry());
+
+ // THEN only the NON-bubble children are dismissed
+ List<ExpandableNotificationRow> childrenRows = groupSummary.getNotificationChildren();
+ verify(mNotificationEntryManager, times(1)).performRemoveNotification(
+ childrenRows.get(0).getEntry().getSbn(), REASON_GROUP_SUMMARY_CANCELED);
+ verify(mNotificationEntryManager, times(1)).performRemoveNotification(
+ childrenRows.get(1).getEntry().getSbn(), REASON_GROUP_SUMMARY_CANCELED);
+ verify(mNotificationEntryManager, never()).performRemoveNotification(
+ eq(groupedBubble.getEntry().getSbn()), anyInt());
+
+ // THEN the bubble child is suppressed from the shade
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ groupedBubble.getEntry()));
+
+ // THEN the summary is removed from GroupManager
+ verify(mNotificationGroupManager, times(1)).onEntryRemoved(groupSummary.getEntry());
+ }
+
static class TestableBubbleController extends BubbleController {
// Let's assume surfaces can be synchronized immediately.
TestableBubbleController(Context context,
@@ -779,11 +870,15 @@ public class BubbleControllerTest extends SysuiTestCase {
ZenModeController zenModeController,
NotificationLockscreenUserManager lockscreenUserManager,
NotificationGroupManager groupManager,
- NotificationEntryManager entryManager) {
+ NotificationEntryManager entryManager,
+ NotifPipeline notifPipeline,
+ FeatureFlags featureFlags,
+ DumpController dumpController) {
super(context,
notificationShadeWindowController, statusBarStateController, shadeController,
data, Runnable::run, configurationController, interruptionStateProvider,
- zenModeController, lockscreenUserManager, groupManager, entryManager);
+ zenModeController, lockscreenUserManager, groupManager, entryManager,
+ notifPipeline, featureFlags, dumpController);
setInflateSynchronously(true);
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
new file mode 100644
index 000000000000..72405fc519fa
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
@@ -0,0 +1,842 @@
+/*
+ * 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.bubbles;
+
+import static android.app.Notification.FLAG_BUBBLE;
+import static android.service.notification.NotificationListenerService.REASON_GROUP_SUMMARY_CANCELED;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.IActivityManager;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.res.Resources;
+import android.hardware.face.FaceManager;
+import android.service.notification.ZenModeConfig;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.WindowManager;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.colorextraction.ColorExtractor;
+import com.android.systemui.DumpController;
+import com.android.systemui.SystemUIFactory;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.colorextraction.SysuiColorExtractor;
+import com.android.systemui.plugins.statusbar.StatusBarStateController;
+import com.android.systemui.statusbar.FeatureFlags;
+import com.android.systemui.statusbar.NotificationLockscreenUserManager;
+import com.android.systemui.statusbar.NotificationPresenter;
+import com.android.systemui.statusbar.SuperStatusBarViewFactory;
+import com.android.systemui.statusbar.SysuiStatusBarStateController;
+import com.android.systemui.statusbar.notification.NotificationEntryManager;
+import com.android.systemui.statusbar.notification.NotificationFilter;
+import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider;
+import com.android.systemui.statusbar.notification.collection.NotifPipeline;
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
+import com.android.systemui.statusbar.notification.row.ActivatableNotificationView;
+import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
+import com.android.systemui.statusbar.notification.row.NotificationTestHelper;
+import com.android.systemui.statusbar.notification.row.dagger.NotificationRowComponent;
+import com.android.systemui.statusbar.phone.DozeParameters;
+import com.android.systemui.statusbar.phone.KeyguardBypassController;
+import com.android.systemui.statusbar.phone.NotificationGroupManager;
+import com.android.systemui.statusbar.phone.NotificationShadeWindowController;
+import com.android.systemui.statusbar.phone.ShadeController;
+import com.android.systemui.statusbar.policy.BatteryController;
+import com.android.systemui.statusbar.policy.ConfigurationController;
+import com.android.systemui.statusbar.policy.HeadsUpManager;
+import com.android.systemui.statusbar.policy.ZenModeController;
+import com.android.systemui.util.InjectionInflationController;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.List;
+
+/**
+ * Tests the NotifPipeline setup with BubbleController.
+ * The NotificationEntryManager setup with BubbleController is tested in
+ * {@link BubbleControllerTest}.
+ */
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class NewNotifPipelineBubbleControllerTest extends SysuiTestCase {
+ @Mock
+ private NotificationEntryManager mNotificationEntryManager;
+ @Mock
+ private NotificationGroupManager mNotificationGroupManager;
+ @Mock
+ private BubbleController.NotifCallback mNotifCallback;
+ @Mock
+ private WindowManager mWindowManager;
+ @Mock
+ private IActivityManager mActivityManager;
+ @Mock
+ private DozeParameters mDozeParameters;
+ @Mock
+ private ConfigurationController mConfigurationController;
+ @Mock
+ private ZenModeController mZenModeController;
+ @Mock
+ private ZenModeConfig mZenModeConfig;
+ @Mock
+ private FaceManager mFaceManager;
+ @Mock
+ private NotificationLockscreenUserManager mLockscreenUserManager;
+ @Mock
+ private SysuiStatusBarStateController mStatusBarStateController;
+ @Mock
+ private KeyguardBypassController mKeyguardBypassController;
+
+ @Captor
+ private ArgumentCaptor<NotifCollectionListener> mNotifListenerCaptor;
+
+ private TestableBubbleController mBubbleController;
+ private NotificationShadeWindowController mNotificationShadeWindowController;
+ private NotifCollectionListener mEntryListener;
+
+ private NotificationTestHelper mNotificationTestHelper;
+ private ExpandableNotificationRow mRow;
+ private ExpandableNotificationRow mRow2;
+ private ExpandableNotificationRow mNonBubbleNotifRow;
+
+ @Mock
+ private BubbleController.BubbleStateChangeListener mBubbleStateChangeListener;
+ @Mock
+ private BubbleController.BubbleExpandListener mBubbleExpandListener;
+ @Mock
+ private PendingIntent mDeleteIntent;
+ @Mock
+ private SysuiColorExtractor mColorExtractor;
+ @Mock
+ ColorExtractor.GradientColors mGradientColors;
+ @Mock
+ private Resources mResources;
+ @Mock
+ private ShadeController mShadeController;
+ @Mock
+ private NotificationRowComponent mNotificationRowComponent;
+ @Mock
+ private NotifPipeline mNotifPipeline;
+ @Mock
+ private FeatureFlags mFeatureFlagsNewPipeline;
+ @Mock
+ private DumpController mDumpController;
+
+ private SuperStatusBarViewFactory mSuperStatusBarViewFactory;
+ private BubbleData mBubbleData;
+
+ private TestableLooper mTestableLooper;
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+
+ mTestableLooper = TestableLooper.get(this);
+
+ mContext.addMockSystemService(FaceManager.class, mFaceManager);
+ when(mColorExtractor.getNeutralColors()).thenReturn(mGradientColors);
+
+ mSuperStatusBarViewFactory = new SuperStatusBarViewFactory(mContext,
+ new InjectionInflationController(SystemUIFactory.getInstance().getRootComponent()),
+ new NotificationRowComponent.Builder() {
+ @Override
+ public NotificationRowComponent.Builder activatableNotificationView(
+ ActivatableNotificationView view) {
+ return this;
+ }
+
+ @Override
+ public NotificationRowComponent build() {
+ return mNotificationRowComponent;
+ }
+ });
+
+ // Bubbles get added to status bar window view
+ mNotificationShadeWindowController = new NotificationShadeWindowController(mContext,
+ mWindowManager, mActivityManager, mDozeParameters, mStatusBarStateController,
+ mConfigurationController, mKeyguardBypassController, mColorExtractor,
+ mSuperStatusBarViewFactory);
+ mNotificationShadeWindowController.attach();
+
+ // Need notifications for bubbles
+ mNotificationTestHelper = new NotificationTestHelper(mContext, mDependency);
+ mRow = mNotificationTestHelper.createBubble(mDeleteIntent);
+ mRow2 = mNotificationTestHelper.createBubble(mDeleteIntent);
+ mNonBubbleNotifRow = mNotificationTestHelper.createRow();
+
+ mZenModeConfig.suppressedVisualEffects = 0;
+ when(mZenModeController.getConfig()).thenReturn(mZenModeConfig);
+
+ TestableNotificationInterruptionStateProvider interruptionStateProvider =
+ new TestableNotificationInterruptionStateProvider(mContext,
+ mock(NotificationFilter.class),
+ mock(StatusBarStateController.class),
+ mock(BatteryController.class));
+ interruptionStateProvider.setUpWithPresenter(
+ mock(NotificationPresenter.class),
+ mock(HeadsUpManager.class),
+ mock(NotificationInterruptionStateProvider.HeadsUpSuppressor.class));
+ mBubbleData = new BubbleData(mContext);
+ when(mFeatureFlagsNewPipeline.isNewNotifPipelineRenderingEnabled()).thenReturn(true);
+ mBubbleController = new TestableBubbleController(mContext,
+ mNotificationShadeWindowController,
+ mStatusBarStateController,
+ mShadeController,
+ mBubbleData,
+ mConfigurationController,
+ interruptionStateProvider,
+ mZenModeController,
+ mLockscreenUserManager,
+ mNotificationGroupManager,
+ mNotificationEntryManager,
+ mNotifPipeline,
+ mFeatureFlagsNewPipeline,
+ mDumpController);
+ mBubbleController.addNotifCallback(mNotifCallback);
+ mBubbleController.setBubbleStateChangeListener(mBubbleStateChangeListener);
+ mBubbleController.setExpandListener(mBubbleExpandListener);
+
+ // Get a reference to the BubbleController's entry listener
+ verify(mNotifPipeline, atLeastOnce())
+ .addCollectionListener(mNotifListenerCaptor.capture());
+ mEntryListener = mNotifListenerCaptor.getValue();
+ }
+
+ @Test
+ public void testAddBubble() {
+ mBubbleController.updateBubble(mRow.getEntry());
+ assertTrue(mBubbleController.hasBubbles());
+
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(true);
+ }
+
+ @Test
+ public void testHasBubbles() {
+ assertFalse(mBubbleController.hasBubbles());
+ mBubbleController.updateBubble(mRow.getEntry());
+ assertTrue(mBubbleController.hasBubbles());
+ }
+
+ @Test
+ public void testRemoveBubble() {
+ mBubbleController.updateBubble(mRow.getEntry());
+ assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+ assertTrue(mBubbleController.hasBubbles());
+ verify(mNotifCallback, times(1)).invalidateNotifications(anyString());
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(true);
+
+ mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+ assertFalse(mNotificationShadeWindowController.getBubblesShowing());
+ assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+ verify(mNotifCallback, times(2)).invalidateNotifications(anyString());
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(false);
+ }
+
+ @Test
+ public void testRemoveBubble_withDismissedNotif() {
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry()));
+
+ // Make it look like dismissed notif
+ mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).setSuppressNotification(true);
+
+ // Now remove the bubble
+ mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+
+ // Since the notif is dismissed, once the bubble is removed, removeNotification gets
+ // called to really remove the notif
+ verify(mNotifCallback, times(1)).removeNotification(eq(mRow.getEntry()), anyInt());
+ assertFalse(mBubbleController.hasBubbles());
+ }
+
+ @Test
+ public void testDismissStack() {
+ mBubbleController.updateBubble(mRow.getEntry());
+ verify(mNotifCallback, times(1)).invalidateNotifications(anyString());
+ assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+ mBubbleController.updateBubble(mRow2.getEntry());
+ verify(mNotifCallback, times(2)).invalidateNotifications(anyString());
+ assertNotNull(mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey()));
+ assertTrue(mBubbleController.hasBubbles());
+
+ mBubbleController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
+ assertFalse(mNotificationShadeWindowController.getBubblesShowing());
+ verify(mNotifCallback, times(3)).invalidateNotifications(anyString());
+ assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+ assertNull(mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey()));
+ }
+
+ @Test
+ public void testExpandCollapseStack() {
+ assertFalse(mBubbleController.isStackExpanded());
+
+ // Mark it as a bubble and add it explicitly
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // We should have bubbles & their notifs should not be suppressed
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ assertFalse(mNotificationShadeWindowController.getBubbleExpanded());
+
+ // Expand the stack
+ BubbleStackView stackView = mBubbleController.getStackView();
+ mBubbleController.expandStack();
+ assertTrue(mBubbleController.isStackExpanded());
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
+ assertTrue(mNotificationShadeWindowController.getBubbleExpanded());
+
+ // Make sure the notif is suppressed
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry()));
+
+ // Collapse
+ mBubbleController.collapseStack();
+ verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().getKey());
+ assertFalse(mBubbleController.isStackExpanded());
+ assertFalse(mNotificationShadeWindowController.getBubbleExpanded());
+ }
+
+ @Test
+ public void testCollapseAfterChangingExpandedBubble() {
+ // Mark it as a bubble and add it explicitly
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mEntryListener.onEntryAdded(mRow2.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+ mBubbleController.updateBubble(mRow2.getEntry());
+
+ // We should have bubbles & their notifs should not be suppressed
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow2.getEntry()));
+
+ // Expand
+ BubbleStackView stackView = mBubbleController.getStackView();
+ mBubbleController.expandStack();
+ assertTrue(mBubbleController.isStackExpanded());
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().getKey());
+
+ // Last added is the one that is expanded
+ assertEquals(mRow2.getEntry(), mBubbleData.getSelectedBubble().getEntry());
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow2.getEntry()));
+
+ // Switch which bubble is expanded
+ mBubbleController.selectBubble(mRow.getEntry().getKey());
+ mBubbleData.setExpanded(true);
+ assertEquals(mRow.getEntry(), stackView.getExpandedBubble().getEntry());
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ // collapse for previous bubble
+ verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey());
+ // expand for selected bubble
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
+
+ // Collapse
+ mBubbleController.collapseStack();
+ assertFalse(mBubbleController.isStackExpanded());
+ }
+
+ @Test
+ public void testExpansionRemovesShowInShadeAndDot() {
+ // Mark it as a bubble and add it explicitly
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // We should have bubbles & their notifs should not be suppressed
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry()));
+
+ mTestableLooper.processAllMessages();
+ assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+
+ // Expand
+ mBubbleController.expandStack();
+ assertTrue(mBubbleController.isStackExpanded());
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
+
+ // Notif is suppressed after expansion
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ // Notif shouldn't show dot after expansion
+ assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+ }
+
+ @Test
+ public void testUpdateWhileExpanded_DoesntChangeShowInShadeAndDot() {
+ // Mark it as a bubble and add it explicitly
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // We should have bubbles & their notifs should not be suppressed
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ mTestableLooper.processAllMessages();
+ assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+
+ // Expand
+ mBubbleController.expandStack();
+ assertTrue(mBubbleController.isStackExpanded());
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
+
+ // Notif is suppressed after expansion
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ // Notif shouldn't show dot after expansion
+ assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+
+ // Send update
+ mEntryListener.onEntryUpdated(mRow.getEntry());
+
+ // Nothing should have changed
+ // Notif is suppressed after expansion
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ // Notif shouldn't show dot after expansion
+ assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+ }
+
+ @Test
+ public void testRemoveLastExpandedCollapses() {
+ // Mark it as a bubble and add it explicitly
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mEntryListener.onEntryAdded(mRow2.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+ mBubbleController.updateBubble(mRow2.getEntry());
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(true);
+
+ // Expand
+ BubbleStackView stackView = mBubbleController.getStackView();
+ mBubbleController.expandStack();
+
+ assertTrue(mBubbleController.isStackExpanded());
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().getKey());
+
+ // Last added is the one that is expanded
+ assertEquals(mRow2.getEntry(), stackView.getExpandedBubble().getEntry());
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow2.getEntry()));
+
+ // Dismiss currently expanded
+ mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(),
+ BubbleController.DISMISS_USER_GESTURE);
+ verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey());
+
+ // Make sure first bubble is selected
+ assertEquals(mRow.getEntry(), stackView.getExpandedBubble().getEntry());
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
+
+ // Dismiss that one
+ mBubbleController.removeBubble(stackView.getExpandedBubble().getEntry(),
+ BubbleController.DISMISS_USER_GESTURE);
+
+ // Make sure state changes and collapse happens
+ verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().getKey());
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(false);
+ assertFalse(mBubbleController.hasBubbles());
+ }
+
+ @Test
+ public void testAutoExpand_fails_noFlag() {
+ assertFalse(mBubbleController.isStackExpanded());
+ setMetadataFlags(mRow.getEntry(),
+ Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE, false /* enableFlag */);
+
+ // Add the auto expand bubble
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // Expansion shouldn't change
+ verify(mBubbleExpandListener, never()).onBubbleExpandChanged(false /* expanded */,
+ mRow.getEntry().getKey());
+ assertFalse(mBubbleController.isStackExpanded());
+
+ // # of bubbles should change
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
+ }
+
+ @Test
+ public void testAutoExpand_succeeds_withFlag() {
+ setMetadataFlags(mRow.getEntry(),
+ Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE, true /* enableFlag */);
+
+ // Add the auto expand bubble
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // Expansion should change
+ verify(mBubbleExpandListener).onBubbleExpandChanged(true /* expanded */,
+ mRow.getEntry().getKey());
+ assertTrue(mBubbleController.isStackExpanded());
+
+ // # of bubbles should change
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
+ }
+
+ @Test
+ public void testSuppressNotif_onInitialNotif() {
+ setMetadataFlags(mRow.getEntry(),
+ Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION, true /* enableFlag */);
+
+ // Add the suppress notif bubble
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // Notif should be suppressed because we were foreground
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ // Dot + flyout is hidden because notif is suppressed
+ assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+ assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showFlyout());
+
+ // # of bubbles should change
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
+ }
+
+ @Test
+ public void testSuppressNotif_onUpdateNotif() {
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // Should not be suppressed
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ // Should show dot
+ assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+
+ // Update to suppress notif
+ setMetadataFlags(mRow.getEntry(),
+ Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION, true /* enableFlag */);
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ // Notif should be suppressed
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+ // Dot + flyout is hidden because notif is suppressed
+ assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+ assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showFlyout());
+
+ // # of bubbles should change
+ verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
+ }
+
+ @Test
+ public void testMarkNewNotificationAsShowInShade() {
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ mTestableLooper.processAllMessages();
+ assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+ }
+
+ @Test
+ public void testAddNotif_notBubble() {
+ mEntryListener.onEntryAdded(mNonBubbleNotifRow.getEntry());
+ mEntryListener.onEntryUpdated(mNonBubbleNotifRow.getEntry());
+
+ verify(mBubbleStateChangeListener, never()).onHasBubblesChanged(anyBoolean());
+ assertThat(mBubbleController.hasBubbles()).isFalse();
+ }
+
+ @Test
+ public void testDeleteIntent_removeBubble_aged() throws PendingIntent.CanceledException {
+ mBubbleController.updateBubble(mRow.getEntry());
+ mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_AGED);
+ verify(mDeleteIntent, never()).send();
+ }
+
+ @Test
+ public void testDeleteIntent_removeBubble_user() throws PendingIntent.CanceledException {
+ mBubbleController.updateBubble(mRow.getEntry());
+ mBubbleController.removeBubble(
+ mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+ verify(mDeleteIntent, times(1)).send();
+ }
+
+ @Test
+ public void testDeleteIntent_dismissStack() throws PendingIntent.CanceledException {
+ mBubbleController.updateBubble(mRow.getEntry());
+ mBubbleController.updateBubble(mRow2.getEntry());
+ mBubbleController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
+ verify(mDeleteIntent, times(2)).send();
+ }
+
+ @Test
+ public void testRemoveBubble_noLongerBubbleAfterUpdate()
+ throws PendingIntent.CanceledException {
+ mBubbleController.updateBubble(mRow.getEntry());
+ assertTrue(mBubbleController.hasBubbles());
+
+ mRow.getEntry().getSbn().getNotification().flags &= ~FLAG_BUBBLE;
+ mEntryListener.onEntryUpdated(mRow.getEntry());
+
+ assertFalse(mBubbleController.hasBubbles());
+ verify(mDeleteIntent, never()).send();
+ }
+
+ @Test
+ public void testRemoveBubble_entryListenerRemove() {
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ assertTrue(mBubbleController.hasBubbles());
+
+ // Removes the notification
+ mEntryListener.onEntryRemoved(mRow.getEntry(), 0);
+ assertFalse(mBubbleController.hasBubbles());
+ }
+
+ @Test
+ public void removeBubble_intercepted() {
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ boolean intercepted = mBubbleController.handleDismissalInterception(mRow.getEntry());
+
+ // Intercept!
+ assertTrue(intercepted);
+ // Should update show in shade state
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry()));
+ }
+
+ @Test
+ public void removeBubble_succeeds_userDismissBubble_userDimissNotif() {
+ mEntryListener.onEntryAdded(mRow.getEntry());
+ mBubbleController.updateBubble(mRow.getEntry());
+
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ // Dismiss the bubble
+ mBubbleController.removeBubble(
+ mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+ assertFalse(mBubbleController.hasBubbles());
+
+ // Dismiss the notification
+ boolean intercepted = mBubbleController.handleDismissalInterception(mRow.getEntry());
+
+ // It's no longer a bubble so we shouldn't intercept
+ assertFalse(intercepted);
+ }
+
+ @Test
+ public void testNotifyShadeSuppressionChange_notificationDismiss() {
+ BubbleController.NotificationSuppressionChangedListener listener =
+ mock(BubbleController.NotificationSuppressionChangedListener.class);
+ mBubbleData.setSuppressionChangedListener(listener);
+
+ mEntryListener.onEntryAdded(mRow.getEntry());
+
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ mBubbleController.handleDismissalInterception(mRow.getEntry());
+
+ // Should update show in shade state
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ // Should notify delegate that shade state changed
+ verify(listener).onBubbleNotificationSuppressionChange(
+ mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+ }
+
+ @Test
+ public void testNotifyShadeSuppressionChange_bubbleExpanded() {
+ BubbleController.NotificationSuppressionChangedListener listener =
+ mock(BubbleController.NotificationSuppressionChangedListener.class);
+ mBubbleData.setSuppressionChangedListener(listener);
+
+ mEntryListener.onEntryAdded(mRow.getEntry());
+
+ assertTrue(mBubbleController.hasBubbles());
+ assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ mBubbleData.setExpanded(true);
+
+ // Once a bubble is expanded the notif is suppressed
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ mRow.getEntry()));
+
+ // Should notify delegate that shade state changed
+ verify(listener).onBubbleNotificationSuppressionChange(
+ mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+ }
+
+ @Test
+ public void testBubbleSummaryDismissal_suppressesSummaryAndBubbleFromShade() throws Exception {
+ // GIVEN a group summary with a bubble child
+ ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0);
+ ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
+ mEntryListener.onEntryAdded(groupedBubble.getEntry());
+ groupSummary.addChildNotification(groupedBubble);
+ assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+
+ // WHEN the summary is dismissed
+ mBubbleController.handleDismissalInterception(groupSummary.getEntry());
+
+ // THEN the summary and bubbled child are suppressed from the shade
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ groupedBubble.getEntry()));
+ assertTrue(mBubbleData.isSummarySuppressed(groupSummary.getEntry().getSbn().getGroupKey()));
+ }
+
+ @Test
+ public void testAppRemovesSummary_removesAllBubbleChildren() throws Exception {
+ // GIVEN a group summary with a bubble child
+ ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(0);
+ ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
+ mEntryListener.onEntryAdded(groupedBubble.getEntry());
+ groupSummary.addChildNotification(groupedBubble);
+ assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+
+ // GIVEN the summary is dismissed
+ mBubbleController.handleDismissalInterception(groupSummary.getEntry());
+
+ // WHEN the summary is cancelled by the app
+ mEntryListener.onEntryRemoved(groupSummary.getEntry(), 0);
+
+ // THEN the summary and its children are removed from bubble data
+ assertFalse(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+ assertFalse(mBubbleData.isSummarySuppressed(
+ groupSummary.getEntry().getSbn().getGroupKey()));
+ }
+
+ @Test
+ public void testSummaryDismissalMarksBubblesHiddenFromShadeAndDismissesNonBubbledChildren()
+ throws Exception {
+ // GIVEN a group summary with two (non-bubble) children and one bubble child
+ ExpandableNotificationRow groupSummary = mNotificationTestHelper.createGroup(2);
+ ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
+ mEntryListener.onEntryAdded(groupedBubble.getEntry());
+ groupSummary.addChildNotification(groupedBubble);
+
+ // WHEN the summary is dismissed
+ mBubbleController.handleDismissalInterception(groupSummary.getEntry());
+
+ // THEN only the NON-bubble children are dismissed
+ List<ExpandableNotificationRow> childrenRows = groupSummary.getNotificationChildren();
+ verify(mNotifCallback, times(1)).removeNotification(
+ childrenRows.get(0).getEntry(), REASON_GROUP_SUMMARY_CANCELED);
+ verify(mNotifCallback, times(1)).removeNotification(
+ childrenRows.get(1).getEntry(), REASON_GROUP_SUMMARY_CANCELED);
+ verify(mNotifCallback, never()).removeNotification(eq(groupedBubble.getEntry()), anyInt());
+
+ // THEN the bubble child still exists as a bubble and is suppressed from the shade
+ assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ groupedBubble.getEntry()));
+
+ // THEN the summary is also suppressed from the shade
+ assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
+ groupSummary.getEntry()));
+ }
+
+ static class TestableBubbleController extends BubbleController {
+ // Let's assume surfaces can be synchronized immediately.
+ TestableBubbleController(Context context,
+ NotificationShadeWindowController notificationShadeWindowController,
+ StatusBarStateController statusBarStateController,
+ ShadeController shadeController,
+ BubbleData data,
+ ConfigurationController configurationController,
+ NotificationInterruptionStateProvider interruptionStateProvider,
+ ZenModeController zenModeController,
+ NotificationLockscreenUserManager lockscreenUserManager,
+ NotificationGroupManager groupManager,
+ NotificationEntryManager entryManager,
+ NotifPipeline notifPipeline,
+ FeatureFlags featureFlags,
+ DumpController dumpController) {
+ super(context,
+ notificationShadeWindowController, statusBarStateController, shadeController,
+ data, Runnable::run, configurationController, interruptionStateProvider,
+ zenModeController, lockscreenUserManager, groupManager, entryManager,
+ notifPipeline, featureFlags, dumpController);
+ setInflateSynchronously(true);
+ }
+ }
+
+ static class TestableNotificationInterruptionStateProvider extends
+ NotificationInterruptionStateProvider {
+
+ TestableNotificationInterruptionStateProvider(Context context,
+ NotificationFilter filter, StatusBarStateController controller,
+ BatteryController batteryController) {
+ super(context, filter, controller, batteryController);
+ mUseHeadsUp = true;
+ }
+ }
+
+ /**
+ * Sets the bubble metadata flags for this entry. These flags are normally set by
+ * NotificationManagerService when the notification is sent, however, these tests do not
+ * go through that path so we set them explicitly when testing.
+ */
+ private void setMetadataFlags(NotificationEntry entry, int flag, boolean enableFlag) {
+ Notification.BubbleMetadata bubbleMetadata =
+ entry.getSbn().getNotification().getBubbleMetadata();
+ int flags = bubbleMetadata.getFlags();
+ if (enableFlag) {
+ flags |= flag;
+ } else {
+ flags &= ~flag;
+ }
+ bubbleMetadata.setFlags(flags);
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java
index 7c94ed20e95a..abc0f3ee8a52 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifCollectionTest.java
@@ -34,6 +34,7 @@ import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyObject;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
@@ -47,6 +48,7 @@ import static java.util.Objects.requireNonNull;
import android.annotation.Nullable;
import android.os.RemoteException;
import android.service.notification.NotificationListenerService.Ranking;
+import android.service.notification.NotificationStats;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import android.util.ArrayMap;
@@ -69,6 +71,7 @@ import com.android.systemui.statusbar.notification.collection.notifcollection.Co
import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionLogger;
+import com.android.systemui.statusbar.notification.collection.notifcollection.NotifDismissInterceptor;
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender;
import com.android.systemui.util.Assert;
@@ -98,11 +101,19 @@ public class NotifCollectionTest extends SysuiTestCase {
@Spy private RecordingCollectionListener mCollectionListener;
@Mock private CollectionReadyForBuildListener mBuildListener;
@Mock private FeatureFlags mFeatureFlags;
+ @Mock private DismissedByUserStats mDismissedByUserStats;
@Spy private RecordingLifetimeExtender mExtender1 = new RecordingLifetimeExtender("Extender1");
@Spy private RecordingLifetimeExtender mExtender2 = new RecordingLifetimeExtender("Extender2");
@Spy private RecordingLifetimeExtender mExtender3 = new RecordingLifetimeExtender("Extender3");
+ @Spy private RecordingDismissInterceptor mInterceptor1 = new RecordingDismissInterceptor(
+ "Interceptor1");
+ @Spy private RecordingDismissInterceptor mInterceptor2 = new RecordingDismissInterceptor(
+ "Interceptor2");
+ @Spy private RecordingDismissInterceptor mInterceptor3 = new RecordingDismissInterceptor(
+ "Interceptor3");
+
@Captor private ArgumentCaptor<BatchableNotificationHandler> mListenerCaptor;
@Captor private ArgumentCaptor<NotificationEntry> mEntryCaptor;
@Captor private ArgumentCaptor<Collection<NotificationEntry>> mBuildListCaptor;
@@ -441,6 +452,169 @@ public class NotifCollectionTest extends SysuiTestCase {
assertEquals(NOT_DISMISSED, entry3.getDismissState());
}
+ @Test
+ public void testDismissInterceptorsAreCalled() throws RemoteException {
+ // GIVEN a collection with notifications with multiple dismiss interceptors
+ mInterceptor1.shouldInterceptDismissal = true;
+ mInterceptor2.shouldInterceptDismissal = true;
+ mInterceptor3.shouldInterceptDismissal = false;
+ mCollection.addNotificationDismissInterceptor(mInterceptor1);
+ mCollection.addNotificationDismissInterceptor(mInterceptor2);
+ mCollection.addNotificationDismissInterceptor(mInterceptor3);
+
+ NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
+ NotificationEntry entry = mCollectionListener.getEntry(notif.key);
+
+ // WHEN a notification is manually dismissed
+ DismissedByUserStats stats = new DismissedByUserStats(
+ NotificationStats.DISMISSAL_SHADE,
+ NotificationStats.DISMISS_SENTIMENT_NEUTRAL,
+ NotificationVisibility.obtain(entry.getKey(), 7, 2, true));
+ mCollection.dismissNotification(entry, stats);
+
+ // THEN all interceptors get checked
+ verify(mInterceptor1).shouldInterceptDismissal(entry);
+ verify(mInterceptor2).shouldInterceptDismissal(entry);
+ verify(mInterceptor3).shouldInterceptDismissal(entry);
+ assertEquals(List.of(mInterceptor1, mInterceptor2), entry.mDismissInterceptors);
+
+ // THEN we never send the dismissal to system server
+ verify(mStatusBarService, never()).onNotificationClear(
+ notif.sbn.getPackageName(),
+ notif.sbn.getTag(),
+ 47,
+ notif.sbn.getUser().getIdentifier(),
+ notif.sbn.getKey(),
+ stats.dismissalSurface,
+ stats.dismissalSentiment,
+ stats.notificationVisibility);
+ }
+
+ @Test
+ public void testDismissInterceptorsCanceledWhenNotifIsUpdated() throws RemoteException {
+ // GIVEN a few lifetime extenders and a couple notifications
+ mCollection.addNotificationDismissInterceptor(mInterceptor1);
+ mCollection.addNotificationDismissInterceptor(mInterceptor2);
+
+ mInterceptor1.shouldInterceptDismissal = true;
+ mInterceptor2.shouldInterceptDismissal = true;
+
+ NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47));
+ NotificationEntry entry = mCollectionListener.getEntry(notif.key);
+
+ // WHEN a notification is manually dismissed and intercepted
+ DismissedByUserStats stats = new DismissedByUserStats(
+ NotificationStats.DISMISSAL_SHADE,
+ NotificationStats.DISMISS_SENTIMENT_NEUTRAL,
+ NotificationVisibility.obtain(entry.getKey(), 7, 2, true));
+ mCollection.dismissNotification(entry, stats);
+ assertEquals(List.of(mInterceptor1, mInterceptor2), entry.mDismissInterceptors);
+ clearInvocations(mInterceptor1, mInterceptor2);
+
+ // WHEN the notification is reposted
+ mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47));
+
+ // THEN all of the active dismissal interceptors are canceled
+ verify(mInterceptor1).cancelDismissInterception(entry);
+ verify(mInterceptor2).cancelDismissInterception(entry);
+ assertEquals(List.of(), entry.mDismissInterceptors);
+
+ // THEN the notification is never sent to system server to dismiss
+ verify(mStatusBarService, never()).onNotificationClear(
+ eq(notif.sbn.getPackageName()),
+ eq(notif.sbn.getTag()),
+ eq(47),
+ eq(notif.sbn.getUser().getIdentifier()),
+ eq(notif.sbn.getKey()),
+ anyInt(),
+ anyInt(),
+ anyObject());
+ }
+
+ @Test
+ public void testEndingAllDismissInterceptorsSendsDismiss() throws RemoteException {
+ // GIVEN a collection with notifications a dismiss interceptor
+ mInterceptor1.shouldInterceptDismissal = true;
+ mCollection.addNotificationDismissInterceptor(mInterceptor1);
+
+ NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
+ NotificationEntry entry = mCollectionListener.getEntry(notif.key);
+
+ // GIVEN a notification is manually dismissed
+ DismissedByUserStats stats = new DismissedByUserStats(
+ NotificationStats.DISMISSAL_SHADE,
+ NotificationStats.DISMISS_SENTIMENT_NEUTRAL,
+ NotificationVisibility.obtain(entry.getKey(), 7, 2, true));
+ mCollection.dismissNotification(entry, stats);
+
+ // WHEN all interceptors end their interception dismissal
+ mInterceptor1.shouldInterceptDismissal = false;
+ mInterceptor1.onEndInterceptionCallback.onEndDismissInterception(mInterceptor1, entry,
+ mDismissedByUserStats);
+
+ // THEN we send the dismissal to system server
+ verify(mStatusBarService, times(1)).onNotificationClear(
+ eq(notif.sbn.getPackageName()),
+ eq(notif.sbn.getTag()),
+ eq(47),
+ eq(notif.sbn.getUser().getIdentifier()),
+ eq(notif.sbn.getKey()),
+ anyInt(),
+ anyInt(),
+ anyObject());
+ }
+
+ @Test
+ public void testEndDismissInterceptionUpdatesDismissInterceptors() throws RemoteException {
+ // GIVEN a collection with notifications with multiple dismiss interceptors
+ mInterceptor1.shouldInterceptDismissal = true;
+ mInterceptor2.shouldInterceptDismissal = true;
+ mInterceptor3.shouldInterceptDismissal = false;
+ mCollection.addNotificationDismissInterceptor(mInterceptor1);
+ mCollection.addNotificationDismissInterceptor(mInterceptor2);
+ mCollection.addNotificationDismissInterceptor(mInterceptor3);
+
+ NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
+ NotificationEntry entry = mCollectionListener.getEntry(notif.key);
+
+ // GIVEN a notification is manually dismissed
+ DismissedByUserStats stats = new DismissedByUserStats(
+ NotificationStats.DISMISSAL_SHADE,
+ NotificationStats.DISMISS_SENTIMENT_NEUTRAL,
+ NotificationVisibility.obtain(entry.getKey(), 7, 2, true));
+ mCollection.dismissNotification(entry, stats);
+
+ // WHEN an interceptor ends its interception
+ mInterceptor1.shouldInterceptDismissal = false;
+ mInterceptor1.onEndInterceptionCallback.onEndDismissInterception(mInterceptor1, entry,
+ mDismissedByUserStats);
+
+ // THEN all interceptors get checked
+ verify(mInterceptor1).shouldInterceptDismissal(entry);
+ verify(mInterceptor2).shouldInterceptDismissal(entry);
+ verify(mInterceptor3).shouldInterceptDismissal(entry);
+
+ // THEN mInterceptor2 is the only dismiss interceptor
+ assertEquals(List.of(mInterceptor2), entry.mDismissInterceptors);
+ }
+
+
+ @Test(expected = IllegalStateException.class)
+ public void testEndingDismissalOfNonInterceptedThrows() throws RemoteException {
+ // GIVEN a collection with notifications with a dismiss interceptor that hasn't been called
+ mInterceptor1.shouldInterceptDismissal = false;
+ mCollection.addNotificationDismissInterceptor(mInterceptor1);
+
+ NotifEvent notif = mNoMan.postNotif(buildNotif(TEST_PACKAGE, 47, "myTag"));
+ NotificationEntry entry = mCollectionListener.getEntry(notif.key);
+
+ // WHEN we try to end the dismissal of an interceptor that didn't intercept the notif
+ mInterceptor1.onEndInterceptionCallback.onEndDismissInterception(mInterceptor1, entry,
+ mDismissedByUserStats);
+
+ // THEN an exception is thrown
+ }
+
@Test(expected = IllegalStateException.class)
public void testDismissingNonExistentNotificationThrows() {
// GIVEN a collection that originally had three notifs, but where one was dismissed
@@ -894,6 +1068,36 @@ public class NotifCollectionTest extends SysuiTestCase {
}
}
+ private static class RecordingDismissInterceptor implements NotifDismissInterceptor {
+ private final String mName;
+
+ public @Nullable OnEndDismissInterception onEndInterceptionCallback;
+ public boolean shouldInterceptDismissal = false;
+
+ private RecordingDismissInterceptor(String name) {
+ mName = name;
+ }
+
+ @Override
+ public String getName() {
+ return mName;
+ }
+
+ @Override
+ public void setCallback(OnEndDismissInterception callback) {
+ this.onEndInterceptionCallback = callback;
+ }
+
+ @Override
+ public boolean shouldInterceptDismissal(NotificationEntry entry) {
+ return shouldInterceptDismissal;
+ }
+
+ @Override
+ public void cancelDismissInterception(NotificationEntry entry) {
+ }
+ }
+
private static final String TEST_PACKAGE = "com.android.test.collection";
private static final String TEST_PACKAGE2 = "com.android.test.collection2";
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
index 4283fad83a23..35b55087873b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationTestHelper.java
@@ -201,9 +201,17 @@ public class NotificationTestHelper {
/**
* Returns an {@link ExpandableNotificationRow} that should be shown as a bubble.
*/
+ public ExpandableNotificationRow createBubbleInGroup()
+ throws Exception {
+ return createBubble(makeBubbleMetadata(null), PKG, true);
+ }
+
+ /**
+ * Returns an {@link ExpandableNotificationRow} that should be shown as a bubble.
+ */
public ExpandableNotificationRow createBubble()
throws Exception {
- return createBubble(makeBubbleMetadata(null), PKG);
+ return createBubble(makeBubbleMetadata(null), PKG, false);
}
/**
@@ -213,7 +221,7 @@ public class NotificationTestHelper {
*/
public ExpandableNotificationRow createBubble(@Nullable PendingIntent deleteIntent)
throws Exception {
- return createBubble(makeBubbleMetadata(deleteIntent), PKG);
+ return createBubble(makeBubbleMetadata(deleteIntent), PKG, false);
}
/**
@@ -223,8 +231,14 @@ public class NotificationTestHelper {
*/
public ExpandableNotificationRow createBubble(BubbleMetadata bubbleMetadata, String pkg)
throws Exception {
+ return createBubble(bubbleMetadata, pkg, false);
+ }
+
+ private ExpandableNotificationRow createBubble(BubbleMetadata bubbleMetadata, String pkg,
+ boolean inGroup)
+ throws Exception {
Notification n = createNotification(false /* isGroupSummary */,
- null /* groupKey */, bubbleMetadata);
+ inGroup ? GROUP_KEY : null /* groupKey */, bubbleMetadata);
n.flags |= FLAG_BUBBLE;
ExpandableNotificationRow row = generateRow(n, pkg, UID, USER_HANDLE,
0 /* extraInflationFlags */, IMPORTANCE_HIGH);