Notification removal with overflow bubbles

Intercept dismissal for overflow bubbles.

Remove notifications for inactive (not in stack and overflow)
bubbles cancelled or suppressed from shade.

Set "isBubble" SysUI flag
- false for inactive bubbles still in shade,
- true for bubbles promoted from overflow.

Account for overflow bubbles in event handling
- onEntryUpdated: remove overflow bubble if no longer a bubble
- onRankingUpdated: remove overflow bubble if blocked
- expandStackAndSelectBubble: promote overflow bubble then expand
- isBubbleNotificationSuppressedFromShade: check overflow bubbles

Update tests
Enable overflow flag

----------------
Fixes: 151104690
Test: manual: cancel oldest bubble => oldest overflow bubble removed
Test: atest SystemUITests

Change-Id: I44bb623f99f9473055787cf10693f7a3bfd1c768
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index e488cf2..4b50378 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -123,7 +123,8 @@
     @Retention(SOURCE)
     @IntDef({DISMISS_USER_GESTURE, DISMISS_AGED, DISMISS_TASK_FINISHED, DISMISS_BLOCKED,
             DISMISS_NOTIF_CANCEL, DISMISS_ACCESSIBILITY_ACTION, DISMISS_NO_LONGER_BUBBLE,
-            DISMISS_USER_CHANGED, DISMISS_GROUP_CANCELLED, DISMISS_INVALID_INTENT})
+            DISMISS_USER_CHANGED, DISMISS_GROUP_CANCELLED, DISMISS_INVALID_INTENT,
+            DISMISS_OVERFLOW_MAX_REACHED})
     @Target({FIELD, LOCAL_VARIABLE, PARAMETER})
     @interface DismissReason {}
 
@@ -137,6 +138,7 @@
     static final int DISMISS_USER_CHANGED = 8;
     static final int DISMISS_GROUP_CANCELLED = 9;
     static final int DISMISS_INVALID_INTENT = 10;
+    static final int DISMISS_OVERFLOW_MAX_REACHED = 11;
 
     private final Context mContext;
     private final NotificationEntryManager mNotificationEntryManager;
@@ -465,7 +467,6 @@
                         if (userRemovedNotif) {
                             return handleDismissalInterception(entry);
                         }
-
                         return false;
                     }
                 });
@@ -736,18 +737,18 @@
      */
     public boolean isBubbleNotificationSuppressedFromShade(NotificationEntry entry) {
         String key = entry.getKey();
-        boolean isBubbleAndSuppressed = mBubbleData.hasBubbleWithKey(key)
-                && !mBubbleData.getBubbleWithKey(key).showInShade();
+        boolean isSuppressedBubble = (mBubbleData.hasAnyBubbleWithKey(key)
+                && !mBubbleData.getAnyBubbleWithkey(key).showInShade());
 
         String groupKey = entry.getSbn().getGroupKey();
         boolean isSuppressedSummary = mBubbleData.isSummarySuppressed(groupKey);
         boolean isSummary = key.equals(mBubbleData.getSummaryKey(groupKey));
-
-        return (isSummary && isSuppressedSummary) || isBubbleAndSuppressed;
+        return (isSummary && isSuppressedSummary) || isSuppressedBubble;
     }
 
     void promoteBubbleFromOverflow(Bubble bubble) {
         bubble.setInflateSynchronously(mInflateSynchronously);
+        setIsBubble(bubble, /* isBubble */ true);
         mBubbleData.promoteBubbleFromOverflow(bubble, mStackView, mBubbleIconFactory);
     }
 
@@ -757,11 +758,16 @@
      * @param notificationKey the notification key for the bubble to be selected
      */
     public void expandStackAndSelectBubble(String notificationKey) {
-        Bubble bubble = mBubbleData.getBubbleWithKey(notificationKey);
-        if (bubble != null) {
+        Bubble bubble = mBubbleData.getBubbleInStackWithKey(notificationKey);
+        if (bubble == null) {
+            bubble = mBubbleData.getOverflowBubbleWithKey(notificationKey);
+            if (bubble != null) {
+                mBubbleData.promoteBubbleFromOverflow(bubble, mStackView, mBubbleIconFactory);
+            }
+        } else if (bubble.getEntry().isBubble()){
             mBubbleData.setSelectedBubble(bubble);
-            mBubbleData.setExpanded(true);
         }
+        mBubbleData.setExpanded(true);
     }
 
     /**
@@ -856,7 +862,7 @@
      */
     @MainThread
     void removeBubble(NotificationEntry entry, int reason) {
-        if (mBubbleData.hasBubbleWithKey(entry.getKey())) {
+        if (mBubbleData.hasAnyBubbleWithKey(entry.getKey())) {
             mBubbleData.notificationEntryRemoved(entry, reason);
         }
     }
@@ -871,7 +877,7 @@
     private void onEntryUpdated(NotificationEntry entry) {
         boolean shouldBubble = mNotificationInterruptStateProvider.shouldBubbleUp(entry)
                 && canLaunchInActivityView(mContext, entry);
-        if (!shouldBubble && mBubbleData.hasBubbleWithKey(entry.getKey())) {
+        if (!shouldBubble && mBubbleData.hasAnyBubbleWithKey(entry.getKey())) {
             // It was previously a bubble but no longer a bubble -- lets remove it
             removeBubble(entry, DISMISS_NO_LONGER_BUBBLE);
         } else if (shouldBubble) {
@@ -910,7 +916,7 @@
             String key = orderedKeys[i];
             NotificationEntry entry = mNotificationEntryManager.getPendingOrActiveNotif(key);
             rankingMap.getRanking(key, mTmpRanking);
-            boolean isActiveBubble = mBubbleData.hasBubbleWithKey(key);
+            boolean isActiveBubble = mBubbleData.hasAnyBubbleWithKey(key);
             if (isActiveBubble && !mTmpRanking.canBubble()) {
                 mBubbleData.notificationEntryRemoved(entry, BubbleController.DISMISS_BLOCKED);
             } else if (entry != null && mTmpRanking.isBubble() && !isActiveBubble) {
@@ -920,6 +926,19 @@
         }
     }
 
+    private void setIsBubble(Bubble b, boolean isBubble) {
+        if (isBubble) {
+            b.getEntry().getSbn().getNotification().flags |= FLAG_BUBBLE;
+        } else {
+            b.getEntry().getSbn().getNotification().flags &= ~FLAG_BUBBLE;
+        }
+        try {
+            mBarService.onNotificationBubbleChanged(b.getKey(), isBubble, 0);
+        } catch (RemoteException e) {
+            // Bad things have happened
+        }
+    }
+
     @SuppressWarnings("FieldCanBeLocal")
     private final BubbleData.Listener mBubbleDataListener = new BubbleData.Listener() {
 
@@ -942,36 +961,36 @@
                 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())
-                            && !bubble.showInShade()) {
+                if (reason == DISMISS_USER_CHANGED) {
+                    continue;
+                }
+                if (!mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
+                    if (!mBubbleData.hasOverflowBubbleWithKey(bubble.getKey())
+                        && (!bubble.showInShade()
+                            || reason == DISMISS_NOTIF_CANCEL
+                            || reason == DISMISS_GROUP_CANCELLED)) {
                         // 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(), REASON_CANCEL);
                         }
                     } else {
-                        // Update the flag for SysUI
-                        bubble.getEntry().getSbn().getNotification().flags &= ~FLAG_BUBBLE;
+                        if (bubble.getEntry().isBubble() && bubble.showInShade()) {
+                            setIsBubble(bubble, /* isBubble */ false);
+                        }
                         if (bubble.getEntry().getRow() != null) {
                             bubble.getEntry().getRow().updateBubbleButton();
                         }
-
-                        // Update the state in NotificationManagerService
-                        try {
-                            mBarService.onNotificationBubbleChanged(bubble.getKey(),
-                                    false /* isBubble */, 0 /* flags */);
-                        } catch (RemoteException e) {
-                        }
                     }
 
-                    final String groupKey = bubble.getEntry().getSbn().getGroupKey();
-                    if (mBubbleData.getBubblesInGroup(groupKey).isEmpty()) {
-                        // Time to potentially remove the summary
-                        for (NotifCallback cb : mCallbacks) {
-                            cb.maybeCancelSummary(bubble.getEntry());
-                        }
+                }
+                final String groupKey = bubble.getEntry().getSbn().getGroupKey();
+                if (mBubbleData.getBubblesInGroup(groupKey).isEmpty()) {
+                    // Time to potentially remove the summary
+                    for (NotifCallback cb : mCallbacks) {
+                        cb.maybeCancelSummary(bubble.getEntry());
                     }
                 }
             }
@@ -1020,7 +1039,7 @@
                 }
                 Log.d(TAG, "\n[BubbleData] overflow:");
                 Log.d(TAG, BubbleDebugConfig.formatBubblesString(mBubbleData.getOverflowBubbles(),
-                        null));
+                        null) + "\n");
             }
         }
     };
@@ -1039,21 +1058,19 @@
         if (entry == null) {
             return false;
         }
-
-        final boolean interceptBubbleDismissal = mBubbleData.hasBubbleWithKey(entry.getKey())
-                && entry.isBubble();
-        final boolean interceptSummaryDismissal = isSummaryOfBubbles(entry);
-
-        if (interceptSummaryDismissal) {
+        if (isSummaryOfBubbles(entry)) {
             handleSummaryDismissalInterception(entry);
-        } else if (interceptBubbleDismissal) {
-            Bubble bubble = mBubbleData.getBubbleWithKey(entry.getKey());
+        } else {
+            Bubble bubble = mBubbleData.getBubbleInStackWithKey(entry.getKey());
+            if (bubble == null || !entry.isBubble()) {
+                bubble = mBubbleData.getOverflowBubbleWithKey(entry.getKey());
+            }
+            if (bubble == null) {
+                return false;
+            }
             bubble.setSuppressNotification(true);
             bubble.setShowDot(false /* show */);
-        } else {
-            return false;
         }
-
         // Update the shade
         for (NotifCallback cb : mCallbacks) {
             cb.invalidateNotifications("BubbleController.handleDismissalInterception");
@@ -1082,11 +1099,11 @@
         if (children != null) {
             for (int i = 0; i < children.size(); i++) {
                 NotificationEntry child = children.get(i);
-                if (mBubbleData.hasBubbleWithKey(child.getKey())) {
+                if (mBubbleData.hasAnyBubbleWithKey(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());
+                    Bubble bubbleChild = mBubbleData.getAnyBubbleWithkey(child.getKey());
                     mNotificationGroupManager.onEntryRemoved(bubbleChild.getEntry());
                     bubbleChild.setSuppressNotification(true);
                     bubbleChild.setShowDot(false /* show */);
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
index a1393cd..35a4811 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
@@ -123,7 +123,7 @@
     private boolean mShowingOverflow;
     private boolean mExpanded;
     private final int mMaxBubbles;
-    private final int mMaxOverflowBubbles;
+    private int mMaxOverflowBubbles;
 
     // State tracked during an operation -- keeps track of what listener events to dispatch.
     private Update mStateChange;
@@ -175,8 +175,16 @@
         return mExpanded;
     }
 
-    public boolean hasBubbleWithKey(String key) {
-        return getBubbleWithKey(key) != null;
+    public boolean hasAnyBubbleWithKey(String key) {
+        return hasBubbleInStackWithKey(key) || hasOverflowBubbleWithKey(key);
+    }
+
+    public boolean hasBubbleInStackWithKey(String key) {
+        return getBubbleInStackWithKey(key) != null;
+    }
+
+    public boolean hasOverflowBubbleWithKey(String key) {
+        return getOverflowBubbleWithKey(key) != null;
     }
 
     @Nullable
@@ -206,6 +214,8 @@
             Log.d(TAG, "promoteBubbleFromOverflow: " + bubble);
         }
         moveOverflowBubbleToPending(bubble);
+        // Preserve new order for next repack, which sorts by last updated time.
+        bubble.markUpdatedAt(mTimeSource.currentTimeMillis());
         bubble.inflate(
                 b -> {
                     notificationEntryUpdated(bubble, /* suppressFlyout */
@@ -221,8 +231,6 @@
     }
 
     private void moveOverflowBubbleToPending(Bubble b) {
-        // Preserve new order for next repack, which sorts by last updated time.
-        b.markUpdatedAt(mTimeSource.currentTimeMillis());
         mOverflowBubbles.remove(b);
         mPendingBubbles.add(b);
     }
@@ -233,15 +241,16 @@
      * for that.
      */
     Bubble getOrCreateBubble(NotificationEntry entry) {
-        Bubble bubble = getBubbleWithKey(entry.getKey());
-        if (bubble == null) {
-            for (int i = 0; i < mOverflowBubbles.size(); i++) {
-                Bubble b = mOverflowBubbles.get(i);
-                if (b.getKey().equals(entry.getKey())) {
-                    moveOverflowBubbleToPending(b);
-                    b.setEntry(entry);
-                    return b;
-                }
+        String key = entry.getKey();
+        Bubble bubble = getBubbleInStackWithKey(entry.getKey());
+        if (bubble != null) {
+            bubble.setEntry(entry);
+        } else {
+            bubble = getOverflowBubbleWithKey(key);
+            if (bubble != null) {
+                moveOverflowBubbleToPending(bubble);
+                bubble.setEntry(entry);
+                return bubble;
             }
             // Check for it in pending
             for (int i = 0; i < mPendingBubbles.size(); i++) {
@@ -253,8 +262,6 @@
             }
             bubble = new Bubble(entry, mSuppressionListener);
             mPendingBubbles.add(bubble);
-        } else {
-            bubble.setEntry(entry);
         }
         return bubble;
     }
@@ -269,7 +276,7 @@
             Log.d(TAG, "notificationEntryUpdated: " + bubble);
         }
         mPendingBubbles.remove(bubble); // No longer pending once we're here
-        Bubble prevBubble = getBubbleWithKey(bubble.getKey());
+        Bubble prevBubble = getBubbleInStackWithKey(bubble.getKey());
         suppressFlyout |= !bubble.getEntry().getRanking().visuallyInterruptive();
 
         if (prevBubble == null) {
@@ -422,6 +429,19 @@
         }
         int indexToRemove = indexForKey(key);
         if (indexToRemove == -1) {
+            if (hasOverflowBubbleWithKey(key)
+                && (reason == BubbleController.DISMISS_NOTIF_CANCEL
+                || reason == BubbleController.DISMISS_GROUP_CANCELLED
+                || reason == BubbleController.DISMISS_NO_LONGER_BUBBLE
+                || reason == BubbleController.DISMISS_BLOCKED)) {
+
+                Bubble b = getOverflowBubbleWithKey(key);
+                if (DEBUG_BUBBLE_DATA) {
+                    Log.d(TAG, "Cancel overflow bubble: " + b);
+                }
+                mStateChange.bubbleRemoved(b, reason);
+                mOverflowBubbles.remove(b);
+            }
             return;
         }
         Bubble bubbleToRemove = mBubbles.get(indexToRemove);
@@ -453,21 +473,23 @@
     }
 
     void overflowBubble(@DismissReason int reason, Bubble bubble) {
-        if (reason == BubbleController.DISMISS_AGED
-                || reason == BubbleController.DISMISS_USER_GESTURE) {
+        if (!(reason == BubbleController.DISMISS_AGED
+                || reason == BubbleController.DISMISS_USER_GESTURE)) {
+            return;
+        }
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "Overflowing: " + bubble);
+        }
+        mOverflowBubbles.add(0, bubble);
+        bubble.stopInflation();
+        if (mOverflowBubbles.size() == mMaxOverflowBubbles + 1) {
+            // Remove oldest bubble.
+            Bubble oldest = mOverflowBubbles.get(mOverflowBubbles.size() - 1);
             if (DEBUG_BUBBLE_DATA) {
-                Log.d(TAG, "Overflowing: " + bubble);
+                Log.d(TAG, "Overflow full. Remove: " + oldest);
             }
-            mOverflowBubbles.add(0, bubble);
-            bubble.stopInflation();
-            if (mOverflowBubbles.size() == mMaxOverflowBubbles + 1) {
-                // Remove oldest bubble.
-                if (DEBUG_BUBBLE_DATA) {
-                    Log.d(TAG, "Overflow full. Remove: " + mOverflowBubbles.get(
-                            mOverflowBubbles.size() - 1));
-                }
-                mOverflowBubbles.remove(mOverflowBubbles.size() - 1);
-            }
+            mStateChange.bubbleRemoved(oldest, BubbleController.DISMISS_OVERFLOW_MAX_REACHED);
+            mOverflowBubbles.remove(oldest);
         }
     }
 
@@ -764,7 +786,17 @@
 
     @VisibleForTesting(visibility = PRIVATE)
     @Nullable
-    Bubble getBubbleWithKey(String key) {
+    Bubble getAnyBubbleWithkey(String key) {
+        Bubble b = getBubbleInStackWithKey(key);
+        if (b == null) {
+            b = getOverflowBubbleWithKey(key);
+        }
+        return b;
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    @Nullable
+    Bubble getBubbleInStackWithKey(String key) {
         for (int i = 0; i < mBubbles.size(); i++) {
             Bubble bubble = mBubbles.get(i);
             if (bubble.getKey().equals(key)) {
@@ -806,6 +838,15 @@
     }
 
     /**
+     * Set maximum number of bubbles allowed in overflow.
+     * This method should only be used in tests, not in production.
+     */
+    @VisibleForTesting
+    void setMaxOverflowBubbles(int maxOverflowBubbles) {
+        mMaxOverflowBubbles = maxOverflowBubbles;
+    }
+
+    /**
      * Description of current bubble data state.
      */
     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java
index 2060391..a888bd5 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java
@@ -71,7 +71,7 @@
     private static final String WHITELISTED_AUTO_BUBBLE_APPS = "whitelisted_auto_bubble_apps";
 
     private static final String ALLOW_BUBBLE_OVERFLOW = "allow_bubble_overflow";
-    private static final boolean ALLOW_BUBBLE_OVERFLOW_DEFAULT = false;
+    private static final boolean ALLOW_BUBBLE_OVERFLOW_DEFAULT = true;
 
     /**
      * When true, if a notification has the information necessary to bubble (i.e. valid
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
index c906931..d870c11 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -906,7 +906,7 @@
                 view -> {
                     showManageMenu(false /* show */);
                     final Bubble bubble = mBubbleData.getSelectedBubble();
-                    if (bubble != null && mBubbleData.hasBubbleWithKey(bubble.getKey())) {
+                    if (bubble != null && mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
                         mUnbubbleConversationCallback.accept(bubble.getEntry());
                     }
                 });
@@ -915,7 +915,7 @@
                 view -> {
                     showManageMenu(false /* show */);
                     final Bubble bubble = mBubbleData.getSelectedBubble();
-                    if (bubble != null && mBubbleData.hasBubbleWithKey(bubble.getKey())) {
+                    if (bubble != null && mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
                         final Intent intent = bubble.getSettingsIntent();
                         collapseStack(() -> {
                             mContext.startActivityAsUser(
@@ -1756,14 +1756,13 @@
         if (mIsExpanded) {
             final View draggedOutBubbleView = (View) mMagnetizedObject.getUnderlyingObject();
             dismissBubbleIfExists(mBubbleData.getBubbleWithView(draggedOutBubbleView));
-
         } else {
             mBubbleData.dismissAll(BubbleController.DISMISS_USER_GESTURE);
         }
     }
 
     private void dismissBubbleIfExists(@Nullable Bubble bubble) {
-        if (bubble != null && mBubbleData.hasBubbleWithKey(bubble.getKey())) {
+        if (bubble != null && mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) {
             mBubbleData.notificationEntryRemoved(
                     bubble.getEntry(), BubbleController.DISMISS_USER_GESTURE);
         }
@@ -2024,8 +2023,8 @@
 
         // If available, update the manage menu's settings option with the expanded bubble's app
         // name and icon.
-        if (show && mBubbleData.hasBubbleWithKey(mExpandedBubble.getKey())) {
-            final Bubble bubble = mBubbleData.getBubbleWithKey(mExpandedBubble.getKey());
+        if (show && mBubbleData.hasBubbleInStackWithKey(mExpandedBubble.getKey())) {
+            final Bubble bubble = mBubbleData.getBubbleInStackWithKey(mExpandedBubble.getKey());
             mManageSettingsIcon.setImageDrawable(bubble.getBadgedAppIcon());
             mManageSettingsText.setText(getResources().getString(
                     R.string.bubbles_app_settings, bubble.getAppName()));
@@ -2241,7 +2240,7 @@
             View child = mBubbleContainer.getChildAt(i);
             if (child instanceof BadgedImageView) {
                 String key = ((BadgedImageView) child).getKey();
-                Bubble bubble = mBubbleData.getBubbleWithKey(key);
+                Bubble bubble = mBubbleData.getBubbleInStackWithKey(key);
                 bubbles.add(bubble);
             }
         }
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 3ef693a..c2b3506 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
@@ -158,6 +158,7 @@
     private NotificationTestHelper mNotificationTestHelper;
     private ExpandableNotificationRow mRow;
     private ExpandableNotificationRow mRow2;
+    private ExpandableNotificationRow mRow3;
     private ExpandableNotificationRow mNonBubbleNotifRow;
 
     @Mock
@@ -232,6 +233,7 @@
                 TestableLooper.get(this));
         mRow = mNotificationTestHelper.createBubble(mDeleteIntent);
         mRow2 = mNotificationTestHelper.createBubble(mDeleteIntent);
+        mRow3 = mNotificationTestHelper.createBubble(mDeleteIntent);
         mNonBubbleNotifRow = mNotificationTestHelper.createRow();
 
         // Return non-null notification data from the NEM
@@ -311,7 +313,7 @@
     @Test
     public void testRemoveBubble() {
         mBubbleController.updateBubble(mRow.getEntry());
-        assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+        assertNotNull(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
         assertTrue(mBubbleController.hasBubbles());
         verify(mNotificationEntryManager).updateNotifications(any());
         verify(mBubbleStateChangeListener).onHasBubblesChanged(true);
@@ -319,7 +321,7 @@
         mBubbleController.removeBubble(
                 mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
         assertFalse(mNotificationShadeWindowController.getBubblesShowing());
-        assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+        assertNull(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
         verify(mNotificationEntryManager, times(2)).updateNotifications(anyString());
         verify(mBubbleStateChangeListener).onHasBubblesChanged(false);
 
@@ -335,8 +337,11 @@
 
         Bubble b = mBubbleData.getOverflowBubbleWithKey(mRow.getEntry().getKey());
         assertThat(mBubbleData.getOverflowBubbles()).isEqualTo(ImmutableList.of(b));
+        verify(mNotificationEntryManager, never()).performRemoveNotification(
+                eq(mRow.getEntry().getSbn()), anyInt());
+        assertFalse(mRow.getEntry().isBubble());
 
-        Bubble b2 = mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey());
+        Bubble b2 = mBubbleData.getBubbleInStackWithKey(mRow2.getEntry().getKey());
         assertThat(mBubbleData.getSelectedBubble()).isEqualTo(b2);
 
         mBubbleController.promoteBubbleFromOverflow(b);
@@ -344,45 +349,49 @@
     }
 
     @Test
-    public void testRemoveBubble_withDismissedNotif() {
-        mEntryListener.onPendingEntryAdded(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
+    public void testCancelOverflowBubble() {
+        mBubbleController.updateBubble(mRow2.getEntry());
+        mBubbleController.updateBubble(mRow.getEntry(), /* suppressFlyout */
+                false, /* showInShade */ true);
         mBubbleController.removeBubble(
                 mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
 
-        // Since the notif is dismissed, once the bubble is removed, performRemoveNotification gets
-        // called to really remove the notif
+        mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_NOTIF_CANCEL);
         verify(mNotificationEntryManager, times(1)).performRemoveNotification(
                 eq(mRow.getEntry().getSbn()), anyInt());
-        assertFalse(mBubbleController.hasBubbles());
+        assertThat(mBubbleData.getOverflowBubbles()).isEmpty();
+        assertFalse(mRow.getEntry().isBubble());
+    }
 
+    @Test
+    public void testUserChange_doesNotRemoveNotif() {
+        mBubbleController.updateBubble(mRow.getEntry());
+        assertTrue(mBubbleController.hasBubbles());
+
+        mBubbleController.removeBubble(
+                mRow.getEntry(), BubbleController.DISMISS_USER_CHANGED);
+        verify(mNotificationEntryManager, never()).performRemoveNotification(
+                eq(mRow.getEntry().getSbn()), anyInt());
+        assertFalse(mBubbleController.hasBubbles());
         assertFalse(mSysUiStateBubblesExpanded);
+        assertTrue(mRow.getEntry().isBubble());
     }
 
     @Test
     public void testDismissStack() {
         mBubbleController.updateBubble(mRow.getEntry());
         verify(mNotificationEntryManager, times(1)).updateNotifications(any());
-        assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+        assertNotNull(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
         mBubbleController.updateBubble(mRow2.getEntry());
         verify(mNotificationEntryManager, times(2)).updateNotifications(any());
-        assertNotNull(mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey()));
+        assertNotNull(mBubbleData.getBubbleInStackWithKey(mRow2.getEntry().getKey()));
         assertTrue(mBubbleController.hasBubbles());
 
         mBubbleData.dismissAll(BubbleController.DISMISS_USER_GESTURE);
         assertFalse(mNotificationShadeWindowController.getBubblesShowing());
         verify(mNotificationEntryManager, times(3)).updateNotifications(any());
-        assertNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
-        assertNull(mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey()));
+        assertNull(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
+        assertNull(mBubbleData.getBubbleInStackWithKey(mRow2.getEntry().getKey()));
 
         assertFalse(mSysUiStateBubblesExpanded);
     }
@@ -452,10 +461,10 @@
                 mRow2.getEntry()));
 
         // Switch which bubble is expanded
-        mBubbleData.setSelectedBubble(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+        mBubbleData.setSelectedBubble(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
         mBubbleData.setExpanded(true);
         assertEquals(mRow.getEntry(),
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry());
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry());
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
 
@@ -483,7 +492,7 @@
                 mRow.getEntry()));
 
         mTestableLooper.processAllMessages();
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Expand
         mBubbleData.setExpanded(true);
@@ -496,7 +505,7 @@
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Notif shouldn't show dot after expansion
-        assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
     }
 
     @Test
@@ -511,7 +520,7 @@
                 mRow.getEntry()));
 
         mTestableLooper.processAllMessages();
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Expand
         mBubbleData.setExpanded(true);
@@ -524,7 +533,7 @@
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Notif shouldn't show dot after expansion
-        assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Send update
         mEntryListener.onPreEntryUpdated(mRow.getEntry());
@@ -534,7 +543,7 @@
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Notif shouldn't show dot after expansion
-        assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
     }
 
     @Test
@@ -557,24 +566,24 @@
 
         // Last added is the one that is expanded
         assertEquals(mRow2.getEntry(),
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry());
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry());
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow2.getEntry()));
 
         // Dismiss currently expanded
         mBubbleController.removeBubble(
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
                 BubbleController.DISMISS_USER_GESTURE);
         verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey());
 
         // Make sure first bubble is selected
         assertEquals(mRow.getEntry(),
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry());
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry());
         verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
 
         // Dismiss that one
         mBubbleController.removeBubble(
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
                 BubbleController.DISMISS_USER_GESTURE);
 
         // Make sure state changes and collapse happens
@@ -639,8 +648,8 @@
         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());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showFlyout());
 
         // # of bubbles should change
         verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
@@ -656,7 +665,7 @@
         assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Should show dot
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Update to suppress notif
         setMetadataFlags(mRow.getEntry(),
@@ -667,8 +676,8 @@
         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());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showFlyout());
 
         // # of bubbles should change
         verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
@@ -699,7 +708,7 @@
                 mRow.getEntry()));
 
         mTestableLooper.processAllMessages();
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
     }
 
     @Test
@@ -812,7 +821,29 @@
     }
 
     @Test
-    public void removeBubble_succeeds_userDismissBubble_userDimissNotif() {
+    public void removeNotif_inOverflow_intercepted() {
+        // Get bubble with notif in shade.
+        mEntryListener.onPendingEntryAdded(mRow.getEntry());
+        mBubbleController.updateBubble(mRow.getEntry());
+        assertTrue(mBubbleController.hasBubbles());
+        assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
+                mRow.getEntry()));
+
+        // Dismiss the bubble into overflow.
+        mBubbleController.removeBubble(
+                mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+        assertFalse(mBubbleController.hasBubbles());
+
+        boolean intercepted = mRemoveInterceptor.onNotificationRemoveRequested(
+                mRow.getEntry().getKey(), mRow.getEntry(), REASON_CANCEL);
+
+        // Notif is no longer a bubble, but still in overflow, so we intercept removal.
+        assertTrue(intercepted);
+    }
+
+    @Test
+    public void removeNotif_notInOverflow_notIntercepted() {
+        // Get bubble with notif in shade.
         mEntryListener.onPendingEntryAdded(mRow.getEntry());
         mBubbleController.updateBubble(mRow.getEntry());
 
@@ -820,20 +851,43 @@
         assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
 
-        // Dismiss the bubble
         mBubbleController.removeBubble(
-                mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+                mRow.getEntry(), BubbleController.DISMISS_NO_LONGER_BUBBLE);
         assertFalse(mBubbleController.hasBubbles());
 
-        // Dismiss the notification
         boolean intercepted = mRemoveInterceptor.onNotificationRemoveRequested(
                 mRow.getEntry().getKey(), mRow.getEntry(), REASON_CANCEL);
 
-        // It's no longer a bubble so we shouldn't intercept
+        // Notif is no longer a bubble, so we should not intercept removal.
         assertFalse(intercepted);
     }
 
     @Test
+    public void testOverflowBubble_maxReached_notInShade_bubbleRemoved() {
+        mBubbleController.updateBubble(
+                mRow.getEntry(), /* suppressFlyout */ false, /* showInShade */ false);
+        mBubbleController.updateBubble(
+                mRow2.getEntry(), /* suppressFlyout */ false, /* showInShade */ false);
+        mBubbleController.updateBubble(
+                mRow3.getEntry(), /* suppressFlyout */ false, /* showInShade */ false);
+        assertEquals(mBubbleData.getBubbles().size(), 3);
+
+        mBubbleData.setMaxOverflowBubbles(1);
+        mBubbleController.removeBubble(
+                mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+        assertEquals(mBubbleData.getBubbles().size(), 2);
+        assertEquals(mBubbleData.getOverflowBubbles().size(), 1);
+
+        mBubbleController.removeBubble(
+                mRow2.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+        // Overflow max of 1 is reached; mRow is oldest, so it gets removed
+        verify(mNotificationEntryManager, times(1)).performRemoveNotification(
+                mRow.getEntry().getSbn(), REASON_CANCEL);
+        assertEquals(mBubbleData.getBubbles().size(), 1);
+        assertEquals(mBubbleData.getOverflowBubbles().size(), 1);
+    }
+
+    @Test
     public void testNotifyShadeSuppressionChange_notificationDismiss() {
         BubbleController.NotificationSuppressionChangedListener listener =
                 mock(BubbleController.NotificationSuppressionChangedListener.class);
@@ -854,7 +908,7 @@
 
         // Should notify delegate that shade state changed
         verify(listener).onBubbleNotificationSuppressionChange(
-                mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+                mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
     }
 
     @Test
@@ -877,7 +931,7 @@
 
         // Should notify delegate that shade state changed
         verify(listener).onBubbleNotificationSuppressionChange(
-                mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+                mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
     }
 
     @Test
@@ -887,7 +941,7 @@
         ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
         mEntryListener.onPendingEntryAdded(groupedBubble.getEntry());
         groupSummary.addChildNotification(groupedBubble);
-        assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+        assertTrue(mBubbleData.hasBubbleInStackWithKey(groupedBubble.getEntry().getKey()));
 
         // WHEN the summary is dismissed
         mBubbleController.handleDismissalInterception(groupSummary.getEntry());
@@ -905,7 +959,7 @@
         ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
         mEntryListener.onPendingEntryAdded(groupedBubble.getEntry());
         groupSummary.addChildNotification(groupedBubble);
-        assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+        assertTrue(mBubbleData.hasBubbleInStackWithKey(groupedBubble.getEntry().getKey()));
 
         // GIVEN the summary is dismissed
         mBubbleController.handleDismissalInterception(groupSummary.getEntry());
@@ -914,7 +968,7 @@
         mEntryListener.onEntryRemoved(groupSummary.getEntry(), null, false, REASON_APP_CANCEL);
 
         // THEN the summary and its children are removed from bubble data
-        assertFalse(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+        assertFalse(mBubbleData.hasBubbleInStackWithKey(groupedBubble.getEntry().getKey()));
         assertFalse(mBubbleData.isSummarySuppressed(
                 groupSummary.getEntry().getSbn().getGroupKey()));
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java
index d2f9127..66f119a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java
@@ -20,8 +20,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
@@ -285,6 +288,25 @@
         assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
     }
 
+    @Test
+    public void testOverflowBubble_maxReached_bubbleRemoved() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        mBubbleData.setListener(mListener);
+
+        mBubbleData.setMaxOverflowBubbles(1);
+        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA1));
+
+        // Overflow max of 1 is reached; A1 is oldest, so it gets removed
+        mBubbleData.notificationEntryRemoved(mEntryA2, BubbleController.DISMISS_USER_GESTURE);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
+    }
+
     /**
      * Verifies that new bubbles insert to the left when collapsed, carrying along grouped bubbles.
      * <p>
@@ -473,6 +495,32 @@
     }
 
     /**
+     * Verifies that overflow bubbles are canceled on notif entry removal.
+     */
+    @Test
+    public void test_removeOverflowBubble_forCanceledNotif() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        sendUpdatedEntryAtTime(mEntryB1, 4000);
+        sendUpdatedEntryAtTime(mEntryB2, 5000);
+        sendUpdatedEntryAtTime(mEntryB3, 6000); // [A2, A3, B1, B2, B3], overflow: [A1]
+        sendUpdatedEntryAtTime(mEntryC1, 7000); // [A3, B1, B2, B3, C1], overflow: [A2, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_NOTIF_CANCEL);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA2, BubbleController.DISMISS_GROUP_CANCELLED);
+        verifyUpdateReceived();
+        assertOverflowChangedTo(ImmutableList.of());
+    }
+
+    /**
      * Verifies that when the selected bubble is removed with the stack in the collapsed state,
      * the selection moves to the next most-recently updated bubble.
      */
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
index 8e6fc8a..23dfb7c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
@@ -283,20 +283,20 @@
     @Test
     public void testRemoveBubble() {
         mBubbleController.updateBubble(mRow.getEntry());
-        assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+        assertNotNull(mBubbleData.getBubbleInStackWithKey(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()));
+        assertNull(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
         verify(mNotifCallback, times(2)).invalidateNotifications(anyString());
         verify(mBubbleStateChangeListener).onHasBubblesChanged(false);
     }
 
     @Test
-    public void testRemoveBubble_withDismissedNotif() {
+    public void testRemoveBubble_withDismissedNotif_inOverflow() {
         mEntryListener.onEntryAdded(mRow.getEntry());
         mBubbleController.updateBubble(mRow.getEntry());
 
@@ -304,13 +304,34 @@
         assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry()));
 
         // Make it look like dismissed notif
-        mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).setSuppressNotification(true);
+        mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).setSuppressNotification(true);
 
         // Now remove the bubble
         mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_USER_GESTURE);
+        assertTrue(mBubbleData.hasOverflowBubbleWithKey(mRow.getEntry().getKey()));
 
-        // Since the notif is dismissed, once the bubble is removed, removeNotification gets
-        // called to really remove the notif
+        // We don't remove the notification since the bubble is still in overflow.
+        verify(mNotifCallback, never()).removeNotification(eq(mRow.getEntry()), anyInt());
+        assertFalse(mBubbleController.hasBubbles());
+    }
+
+    @Test
+    public void testRemoveBubble_withDismissedNotif_notInOverflow() {
+        mEntryListener.onEntryAdded(mRow.getEntry());
+        mBubbleController.updateBubble(mRow.getEntry());
+
+        assertTrue(mBubbleController.hasBubbles());
+        assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry()));
+
+        // Make it look like dismissed notif
+        mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).setSuppressNotification(true);
+
+        // Now remove the bubble
+        mBubbleController.removeBubble(mRow.getEntry(), BubbleController.DISMISS_NOTIF_CANCEL);
+        assertFalse(mBubbleData.hasOverflowBubbleWithKey(mRow.getEntry().getKey()));
+
+        // Since the notif is dismissed and not in overflow, 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());
     }
@@ -319,17 +340,17 @@
     public void testDismissStack() {
         mBubbleController.updateBubble(mRow.getEntry());
         verify(mNotifCallback, times(1)).invalidateNotifications(anyString());
-        assertNotNull(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+        assertNotNull(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
         mBubbleController.updateBubble(mRow2.getEntry());
         verify(mNotifCallback, times(2)).invalidateNotifications(anyString());
-        assertNotNull(mBubbleData.getBubbleWithKey(mRow2.getEntry().getKey()));
+        assertNotNull(mBubbleData.getBubbleInStackWithKey(mRow2.getEntry().getKey()));
         assertTrue(mBubbleController.hasBubbles());
 
         mBubbleData.dismissAll(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()));
+        assertNull(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
+        assertNull(mBubbleData.getBubbleInStackWithKey(mRow2.getEntry().getKey()));
     }
 
     @Test
@@ -389,10 +410,10 @@
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow2.getEntry()));
 
         // Switch which bubble is expanded
-        mBubbleData.setSelectedBubble(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+        mBubbleData.setSelectedBubble(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
         mBubbleData.setExpanded(true);
         assertEquals(mRow.getEntry(),
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry());
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry());
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
 
@@ -417,7 +438,7 @@
         assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(mRow.getEntry()));
 
         mTestableLooper.processAllMessages();
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Expand
         mBubbleData.setExpanded(true);
@@ -428,7 +449,7 @@
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Notif shouldn't show dot after expansion
-        assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
     }
 
     @Test
@@ -443,7 +464,7 @@
                 mRow.getEntry()));
 
         mTestableLooper.processAllMessages();
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Expand
         mBubbleData.setExpanded(true);
@@ -454,7 +475,7 @@
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Notif shouldn't show dot after expansion
-        assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Send update
         mEntryListener.onEntryUpdated(mRow.getEntry());
@@ -464,7 +485,7 @@
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Notif shouldn't show dot after expansion
-        assertFalse(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
     }
 
     @Test
@@ -485,24 +506,24 @@
 
         // Last added is the one that is expanded
         assertEquals(mRow2.getEntry(),
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry());
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry());
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow2.getEntry()));
 
         // Dismiss currently expanded
         mBubbleController.removeBubble(
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
                 BubbleController.DISMISS_USER_GESTURE);
         verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey());
 
         // Make sure first bubble is selected
         assertEquals(mRow.getEntry(),
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry());
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry());
         verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
 
         // Dismiss that one
         mBubbleController.removeBubble(
-                mBubbleData.getBubbleWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
+                mBubbleData.getBubbleInStackWithKey(stackView.getExpandedBubble().getKey()).getEntry(),
                 BubbleController.DISMISS_USER_GESTURE);
 
         // Make sure state changes and collapse happens
@@ -561,8 +582,8 @@
         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());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showFlyout());
 
         // # of bubbles should change
         verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
@@ -576,7 +597,7 @@
         assertFalse(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 mRow.getEntry()));
         // Should show dot
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
 
         // Update to suppress notif
         setMetadataFlags(mRow.getEntry(),
@@ -587,8 +608,8 @@
         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());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
+        assertFalse(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showFlyout());
 
         // # of bubbles should change
         verify(mBubbleStateChangeListener).onHasBubblesChanged(true /* hasBubbles */);
@@ -601,7 +622,7 @@
                 mRow.getEntry()));
 
         mTestableLooper.processAllMessages();
-        assertTrue(mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()).showDot());
+        assertTrue(mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()).showDot());
     }
 
     @Test
@@ -679,7 +700,7 @@
     }
 
     @Test
-    public void removeBubble_succeeds_userDismissBubble_userDimissNotif() {
+    public void removeBubble_dismissIntoOverflow_intercepted() {
         mEntryListener.onEntryAdded(mRow.getEntry());
         mBubbleController.updateBubble(mRow.getEntry());
 
@@ -695,7 +716,28 @@
         // Dismiss the notification
         boolean intercepted = mBubbleController.handleDismissalInterception(mRow.getEntry());
 
-        // It's no longer a bubble so we shouldn't intercept
+        // Intercept dismissal since bubble is going into overflow
+        assertTrue(intercepted);
+    }
+
+    @Test
+    public void removeBubble_notIntercepted() {
+        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_NOTIF_CANCEL);
+        assertFalse(mBubbleController.hasBubbles());
+
+        // Dismiss the notification
+        boolean intercepted = mBubbleController.handleDismissalInterception(mRow.getEntry());
+
+        // Not a bubble anymore so we don't intercept dismissal.
         assertFalse(intercepted);
     }
 
@@ -719,7 +761,7 @@
 
         // Should notify delegate that shade state changed
         verify(listener).onBubbleNotificationSuppressionChange(
-                mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+                mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
     }
 
     @Test
@@ -742,7 +784,7 @@
 
         // Should notify delegate that shade state changed
         verify(listener).onBubbleNotificationSuppressionChange(
-                mBubbleData.getBubbleWithKey(mRow.getEntry().getKey()));
+                mBubbleData.getBubbleInStackWithKey(mRow.getEntry().getKey()));
     }
 
     @Test
@@ -752,7 +794,7 @@
         ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
         mEntryListener.onEntryAdded(groupedBubble.getEntry());
         groupSummary.addChildNotification(groupedBubble);
-        assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+        assertTrue(mBubbleData.hasBubbleInStackWithKey(groupedBubble.getEntry().getKey()));
 
         // WHEN the summary is dismissed
         mBubbleController.handleDismissalInterception(groupSummary.getEntry());
@@ -770,7 +812,7 @@
         ExpandableNotificationRow groupedBubble = mNotificationTestHelper.createBubbleInGroup();
         mEntryListener.onEntryAdded(groupedBubble.getEntry());
         groupSummary.addChildNotification(groupedBubble);
-        assertTrue(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+        assertTrue(mBubbleData.hasBubbleInStackWithKey(groupedBubble.getEntry().getKey()));
 
         // GIVEN the summary is dismissed
         mBubbleController.handleDismissalInterception(groupSummary.getEntry());
@@ -779,7 +821,7 @@
         mEntryListener.onEntryRemoved(groupSummary.getEntry(), 0);
 
         // THEN the summary and its children are removed from bubble data
-        assertFalse(mBubbleData.hasBubbleWithKey(groupedBubble.getEntry().getKey()));
+        assertFalse(mBubbleData.hasBubbleInStackWithKey(groupedBubble.getEntry().getKey()));
         assertFalse(mBubbleData.isSummarySuppressed(
                 groupSummary.getEntry().getSbn().getGroupKey()));
     }
@@ -805,7 +847,7 @@
         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(mBubbleData.hasBubbleInStackWithKey(groupedBubble.getEntry().getKey()));
         assertTrue(mBubbleController.isBubbleNotificationSuppressedFromShade(
                 groupedBubble.getEntry()));