Add BubbleCoordinator

When FeatureFlags.isNewNotifPipelineRenderingEnabled, the
BubbleController will use the NotifPipeline as its source of
notifications instead of the NotificationEntryManager. Note: in this new
pipeline, the GroupManager is not longer necessary; instead, the
ShadeListBuilder handles grouping. The BubbleCoorinator handles
filtering and removal intercepts (see BubbleCoordinator documentation
for more information).

Note: In BubbleController.handleSummaryDismissalInterception
we now intercept auto-generated summaries so the children
of the group aren't dismissed too early in the new notif pipeline. These
summaries will still be removed by NEM via the maybeCancelSummary
method. In the new pipelipe, the summaries will be pruned in
ShadeListBuilder and removed when system server gets back to us to
remove it.

This CL also includes a fix for a bug that occurs when a user-dismissed
summary is associated with bubbles that are hidden from the shade AND
regular notifications in the shade. Previously, if the summary was
dismissed, the summary would be intercepted and the children that
weren't hidden from the shade would show in the shade (ungrouped)
even though they should be dismissed. The fix is to manually dismiss
these children in BubbleController in handleSummaryDismissalInterception.

Test: atest NotifCollectionTest
Test: atest NewNotifPipelineBubbleControllerTest
Test: atest BubbleControllerTest
Test: atest SystemUITests
Change-Id: Id1639f2a5997103b2fd14971433b764c3b1b0112
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index 792b940..05838ab 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 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 @@
  * 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 @@
 
     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 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 @@
             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 @@
             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 @@
 
         mNotificationEntryManager = entryManager;
         mNotificationGroupManager = groupManager;
-        setupNEM();
+        mNotifPipeline = notifPipeline;
+
+        if (!featureFlags.isNewNotifPipelineRenderingEnabled()) {
+            setupNEM();
+        } else {
+            setupNotifPipeline();
+        }
 
         mNotificationShadeWindowController = notificationShadeWindowController;
         mStatusBarStateListener = new StatusBarStateListener();
@@ -396,6 +421,14 @@
                     }
 
                     @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 @@
                 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 @@
 
         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 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 @@
         });
     }
 
+    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 @@
             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 @@
         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 @@
                 && (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 @@
         }
     }
 
+    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 @@
                 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 @@
                         // 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 @@
             }
 
             for (NotifCallback cb : mCallbacks) {
-                cb.invalidateNotificationFilter("BubbleData.Listener.applyUpdate");
+                cb.invalidateNotifications("BubbleData.Listener.applyUpdate");
             }
             updateStack();
 
@@ -930,124 +1045,85 @@
     };
 
     /**
-     * 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) {
-            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);
-        }
-
-        // 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) {
+        if (interceptSummaryDismissal) {
+            handleSummaryDismissalInterception(entry);
+        } else if (interceptBubbleDismissal) {
+            Bubble bubble = mBubbleData.getBubbleWithKey(entry.getKey());
             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);
+        } else {
             return false;
         }
-        return false;
+
+        // Update the shade
+        for (NotifCallback cb : mCallbacks) {
+            cb.invalidateNotifications("BubbleController.handleDismissalInterception");
+        }
+        return true;
     }
 
-    private boolean handleSummaryRemovalInterception(NotificationEntry summary,
-            boolean userRemovedNotif) {
-        String groupKey = summary.getSbn().getGroupKey();
-        ArrayList<Bubble> bubbleChildren = mBubbleData.getBubblesInGroup(groupKey);
-
-        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;
-                }
-            }
-
-            // 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);
-            }
+    private boolean isSummaryOfBubbles(NotificationEntry entry) {
+        if (entry == null) {
             return false;
         }
+
+        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();
+    }
+
+    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);
+                    }
+                }
+            }
+        }
+
+        // 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 50a5063..0d5261d 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 @@
 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.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 @@
                             // 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 @@
             }
             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 @@
         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 34d3c24..645696d0 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 @@
                                 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 7fe229c..3fa1954 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 @@
                         .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 3b2fe94..38d8d97 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.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 @@
     @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 @@
         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 @@
             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 @@
         for (NotificationEntry canceledEntry : canceledEntries) {
             tryRemoveNotification(canceledEntry);
         }
-
         rebuildList();
     }
 
@@ -307,11 +325,11 @@
             // 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 @@
 
         if (!isLifetimeExtended(entry)) {
             mNotificationSet.remove(entry.getKey());
+            cancelDismissInterception(entry);
             dispatchOnEntryRemoved(entry, entry.mCancellationReason);
             dispatchOnEntryCleanUp(entry);
             return true;
@@ -436,6 +455,17 @@
         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 @@
         }
     }
 
+    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 5767ad9..d4d2369 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.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 @@
 
     /**
      * 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 a489f3b..006d40d 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.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 @@
     /** 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 0000000..116c70c
--- /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 0a1e09f..7a9547c 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 @@
             RankingCoordinator rankingCoordinator,
             ForegroundCoordinator foregroundCoordinator,
             DeviceProvisionedCoordinator deviceProvisionedCoordinator,
+            BubbleCoordinator bubbleCoordinator,
             PreparationCoordinator preparationCoordinator) {
         dumpController.registerDumpable(TAG, this);
 
@@ -61,6 +62,7 @@
         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 14e1503..dc7a50d 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 @@
         })
     }
 
+    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 0000000..3354ad1
--- /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 11f7079..c68d994 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 @@
             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 280d14e..c3b55e2 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.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.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 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.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.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 @@
     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 @@
                 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 @@
                 mZenModeController,
                 mLockscreenUserManager,
                 mNotificationGroupManager,
-                mNotificationEntryManager);
+                mNotificationEntryManager,
+                mNotifPipeline,
+                mFeatureFlagsOldPipeline,
+                mDumpController);
         mBubbleController.setBubbleStateChangeListener(mBubbleStateChangeListener);
         mBubbleController.setExpandListener(mBubbleExpandListener);
 
@@ -261,7 +281,7 @@
         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 @@
 
         // 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 @@
                 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 @@
         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 @@
     @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 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 @@
 
         // 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 @@
         // 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 @@
         // 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 @@
 
         // 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 @@
                 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 @@
                 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 0000000..72405fc
--- /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 7c94ed2..abc0f3e 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.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 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.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 @@
     @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 @@
         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 @@
         }
     }
 
+    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 4283fad..35b5508 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 @@
     /**
      * 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 ExpandableNotificationRow createBubble(@Nullable PendingIntent deleteIntent)
             throws Exception {
-        return createBubble(makeBubbleMetadata(deleteIntent), PKG);
+        return createBubble(makeBubbleMetadata(deleteIntent), PKG, false);
     }
 
     /**
@@ -223,8 +231,14 @@
      */
     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);