Updates BubbleData sorting and grouping to spec

This change adds onBubbleOrderChanged to BubbleStackView
to try to keep the order synchronized. There are some known
issues with the animation and visual state, which will be
addressed in a follow up CL.

Bug: 123542488
Test: BubbleControllerTest BubbleDataTest
Change-Id: Ie5a679df2f3069236f4d67a3fce4189b39b9eb28
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
index 7094d28..ac4a93b 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
@@ -46,7 +46,7 @@
     private long mLastUpdated;
     private long mLastAccessed;
 
-    private static String groupId(NotificationEntry entry) {
+    public static String groupId(NotificationEntry entry) {
         UserHandle user = entry.notification.getUser();
         return user.getIdentifier() + "|" + entry.notification.getPackageName();
     }
@@ -120,11 +120,28 @@
         }
     }
 
+    /**
+     * @return the newer of {@link #getLastUpdateTime()} and {@link #getLastAccessTime()}
+     */
     public long getLastActivity() {
         return Math.max(mLastUpdated, mLastAccessed);
     }
 
     /**
+     * @return the timestamp in milliseconds of the most recent notification entry for this bubble
+     */
+    public long getLastUpdateTime() {
+        return mLastUpdated;
+    }
+
+    /**
+     * @return the timestamp in milliseconds when this bubble was last displayed in expanded state
+     */
+    public long getLastAccessTime() {
+        return mLastAccessed;
+    }
+
+    /**
      * Should be invoked whenever a Bubble is accessed (selected while expanded).
      */
     void markAsAccessedAt(long lastAccessedMillis) {
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index 7d189b2..2d0944a 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -29,6 +29,9 @@
 import static com.android.systemui.statusbar.StatusBarState.SHADE;
 import static com.android.systemui.statusbar.notification.NotificationEntryManager.UNDEFINED_DISMISS_REASON;
 
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.LOCAL_VARIABLE;
+import static java.lang.annotation.ElementType.PARAMETER;
 import static java.lang.annotation.RetentionPolicy.SOURCE;
 
 import android.annotation.Nullable;
@@ -73,6 +76,7 @@
 import com.android.systemui.statusbar.policy.ConfigurationController;
 
 import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
 import java.util.List;
 
 import javax.inject.Inject;
@@ -88,11 +92,12 @@
 public class BubbleController implements ConfigurationController.ConfigurationListener {
 
     private static final String TAG = "BubbleController";
-    private static final boolean DEBUG = true;
+    private static final boolean DEBUG = false;
 
     @Retention(SOURCE)
     @IntDef({DISMISS_USER_GESTURE, DISMISS_AGED, DISMISS_TASK_FINISHED, DISMISS_BLOCKED,
             DISMISS_NOTIF_CANCEL, DISMISS_ACCESSIBILITY_ACTION, DISMISS_NO_LONGER_BUBBLE})
+    @Target({FIELD, LOCAL_VARIABLE, PARAMETER})
     @interface DismissReason {}
 
     static final int DISMISS_USER_GESTURE = 1;
@@ -510,6 +515,9 @@
 
         @Override
         public void onOrderChanged(List<Bubble> bubbles) {
+            if (mStackView != null) {
+                mStackView.updateBubbleOrder(bubbles);
+            }
         }
 
         @Override
@@ -527,13 +535,6 @@
         }
 
         @Override
-        public void showFlyoutText(Bubble bubble, String text) {
-            if (mStackView != null) {
-                mStackView.animateInFlyoutForBubble(bubble);
-            }
-        }
-
-        @Override
         public void apply() {
             mNotificationEntryManager.updateNotifications();
             updateVisibility();
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
index f15ba6e..9156e06 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
@@ -23,6 +23,7 @@
 import android.app.PendingIntent;
 import android.content.Context;
 import android.util.Log;
+import android.util.Pair;
 
 import androidx.annotation.Nullable;
 
@@ -53,10 +54,10 @@
 
     private static final int MAX_BUBBLES = 5;
 
-    private static final Comparator<Bubble> BUBBLES_BY_LAST_ACTIVITY_DESCENDING =
-            Comparator.comparing(Bubble::getLastActivity).reversed();
+    private static final Comparator<Bubble> BUBBLES_BY_SORT_KEY_DESCENDING =
+            Comparator.comparing(BubbleData::sortKey).reversed();
 
-    private static final Comparator<Map.Entry<String, Long>> GROUPS_BY_LAST_ACTIVITY_DESCENDING =
+    private static final Comparator<Map.Entry<String, Long>> GROUPS_BY_MAX_SORT_KEY_DESCENDING =
             Comparator.<Map.Entry<String, Long>, Long>comparing(Map.Entry::getValue).reversed();
 
     /**
@@ -105,9 +106,6 @@
          */
         void onExpandedChanged(boolean expanded);
 
-        /** Flyout text should animate in, showing the given text. */
-        void showFlyoutText(Bubble bubble, String text);
-
         /** Commit any pending operations (since last call of apply()) */
         void apply();
     }
@@ -121,15 +119,19 @@
     private Bubble mSelectedBubble;
     private boolean mExpanded;
 
-    // TODO: ensure this is invalidated at the appropriate time
-    private int mSelectedBubbleExpandedPosition = -1;
+    // State tracked during an operation -- keeps track of what listener events to dispatch.
+    private boolean mExpandedChanged;
+    private boolean mOrderChanged;
+    private boolean mSelectionChanged;
+    private Bubble mUpdatedBubble;
+    private Bubble mAddedBubble;
+    private final List<Pair<Bubble, Integer>> mRemovedBubbles = new ArrayList<>();
 
     private TimeSource mTimeSource = System::currentTimeMillis;
 
     @Nullable
     private Listener mListener;
 
-    @VisibleForTesting
     @Inject
     public BubbleData(Context context) {
         mContext = context;
@@ -154,18 +156,19 @@
     }
 
     public void setExpanded(boolean expanded) {
-        if (setExpandedInternal(expanded)) {
-            dispatchApply();
+        if (DEBUG) {
+            Log.d(TAG, "setExpanded: " + expanded);
         }
+        setExpandedInternal(expanded);
+        dispatchPendingChanges();
     }
 
     public void setSelectedBubble(Bubble bubble) {
         if (DEBUG) {
             Log.d(TAG, "setSelectedBubble: " + bubble);
         }
-        if (setSelectedBubbleInternal(bubble)) {
-            dispatchApply();
-        }
+        setSelectedBubbleInternal(bubble);
+        dispatchPendingChanges();
     }
 
     public void notificationEntryUpdated(NotificationEntry entry) {
@@ -177,12 +180,12 @@
             // Create a new bubble
             bubble = new Bubble(entry, this::onBubbleBlocked);
             doAdd(bubble);
-            dispatchOnBubbleAdded(bubble);
+            trim();
         } else {
             // Updates an existing bubble
             bubble.setEntry(entry);
             doUpdate(bubble);
-            dispatchOnBubbleUpdated(bubble);
+            mUpdatedBubble = bubble;
         }
         if (shouldAutoExpand(entry)) {
             setSelectedBubbleInternal(bubble);
@@ -192,7 +195,15 @@
         } else if (mSelectedBubble == null) {
             setSelectedBubbleInternal(bubble);
         }
-        dispatchApply();
+        dispatchPendingChanges();
+    }
+
+    public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
+        if (DEBUG) {
+            Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
+        }
+        doRemove(entry.key, reason);
+        dispatchPendingChanges();
     }
 
     private void doAdd(Bubble bubble) {
@@ -202,14 +213,21 @@
         int minInsertPoint = 0;
         boolean newGroup = !hasBubbleWithGroupId(bubble.getGroupId());
         if (isExpanded()) {
-            // first bubble of a group goes to the end, otherwise it goes within the existing group
-            minInsertPoint =
-                    newGroup ? mBubbles.size() : findFirstIndexForGroup(bubble.getGroupId());
+            // first bubble of a group goes to the beginning, otherwise within the existing group
+            minInsertPoint = newGroup ? 0 : findFirstIndexForGroup(bubble.getGroupId());
         }
-        insertBubble(minInsertPoint, bubble);
+        if (insertBubble(minInsertPoint, bubble) < mBubbles.size() - 1) {
+            mOrderChanged = true;
+        }
+        mAddedBubble = bubble;
         if (!isExpanded()) {
-            packGroup(findFirstIndexForGroup(bubble.getGroupId()));
+            mOrderChanged |= packGroup(findFirstIndexForGroup(bubble.getGroupId()));
+            // Top bubble becomes selected.
+            setSelectedBubbleInternal(mBubbles.get(0));
         }
+    }
+
+    private void trim() {
         if (mBubbles.size() > MAX_BUBBLES) {
             mBubbles.stream()
                     // sort oldest first (ascending lastActivity)
@@ -217,10 +235,7 @@
                     // skip the selected bubble
                     .filter((b) -> !b.equals(mSelectedBubble))
                     .findFirst()
-                    .ifPresent((b) -> {
-                        doRemove(b.getKey(), BubbleController.DISMISS_AGED);
-                        dispatchApply();
-                    });
+                    .ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
         }
     }
 
@@ -229,43 +244,48 @@
             Log.d(TAG, "doUpdate: " + bubble);
         }
         if (!isExpanded()) {
-            // while collapsed, update causes re-sort
+            // while collapsed, update causes re-pack
+            int prevPos = mBubbles.indexOf(bubble);
             mBubbles.remove(bubble);
-            insertBubble(0, bubble);
-            packGroup(findFirstIndexForGroup(bubble.getGroupId()));
+            int newPos = insertBubble(0, bubble);
+            if (prevPos != newPos) {
+                packGroup(newPos);
+                mOrderChanged = true;
+            }
+            setSelectedBubbleInternal(mBubbles.get(0));
         }
     }
 
-    public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
-        if (DEBUG) {
-            Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
-        }
-        doRemove(entry.key, reason);
-        dispatchApply();
-    }
-
     private void doRemove(String key, @DismissReason int reason) {
         int indexToRemove = indexForKey(key);
-        if (indexToRemove >= 0) {
-            Bubble bubbleToRemove = mBubbles.get(indexToRemove);
-            if (mBubbles.size() == 1) {
-                // Going to become empty, handle specially.
-                setExpandedInternal(false);
-                setSelectedBubbleInternal(null);
-            }
-            mBubbles.remove(indexToRemove);
-            dispatchOnBubbleRemoved(bubbleToRemove, reason);
-
-            // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
-            if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
-                // Move selection to the new bubble at the same position.
-                int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
-                Bubble newSelected = mBubbles.get(newIndex);
-                setSelectedBubbleInternal(newSelected);
-            }
-            bubbleToRemove.setDismissed();
-            maybeSendDeleteIntent(reason, bubbleToRemove.entry);
+        if (indexToRemove == -1) {
+            return;
         }
+        Bubble bubbleToRemove = mBubbles.get(indexToRemove);
+        if (mBubbles.size() == 1) {
+            // Going to become empty, handle specially.
+            setExpandedInternal(false);
+            setSelectedBubbleInternal(null);
+        }
+        if (indexToRemove < mBubbles.size() - 1) {
+            // Removing anything but the last bubble means positions will change.
+            mOrderChanged = true;
+        }
+        mBubbles.remove(indexToRemove);
+        mRemovedBubbles.add(Pair.create(bubbleToRemove, reason));
+        if (!isExpanded()) {
+            mOrderChanged |= repackAll();
+        }
+
+        // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
+        if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
+            // Move selection to the new bubble at the same position.
+            int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
+            Bubble newSelected = mBubbles.get(newIndex);
+            setSelectedBubbleInternal(newSelected);
+        }
+        bubbleToRemove.setDismissed();
+        maybeSendDeleteIntent(reason, bubbleToRemove.entry);
     }
 
     public void dismissAll(@DismissReason int reason) {
@@ -281,87 +301,98 @@
             Bubble bubble = mBubbles.remove(0);
             bubble.setDismissed();
             maybeSendDeleteIntent(reason, bubble.entry);
-            dispatchOnBubbleRemoved(bubble, reason);
+            mRemovedBubbles.add(Pair.create(bubble, reason));
         }
-        dispatchApply();
+        dispatchPendingChanges();
     }
 
-    private void dispatchApply() {
-        if (mListener != null) {
+
+    private void dispatchPendingChanges() {
+        if (mListener == null) {
+            mExpandedChanged = false;
+            mAddedBubble = null;
+            mSelectionChanged = false;
+            mRemovedBubbles.clear();
+            mUpdatedBubble = null;
+            mOrderChanged = false;
+            return;
+        }
+        boolean anythingChanged = false;
+
+        if (mAddedBubble != null) {
+            mListener.onBubbleAdded(mAddedBubble);
+            mAddedBubble = null;
+            anythingChanged = true;
+        }
+
+        // Compat workaround: Always collapse first.
+        if (mExpandedChanged && !mExpanded) {
+            mListener.onExpandedChanged(mExpanded);
+            mExpandedChanged = false;
+            anythingChanged = true;
+        }
+
+        if (mSelectionChanged) {
+            mListener.onSelectionChanged(mSelectedBubble);
+            mSelectionChanged = false;
+            anythingChanged = true;
+        }
+
+        if (!mRemovedBubbles.isEmpty()) {
+            for (Pair<Bubble, Integer> removed : mRemovedBubbles) {
+                mListener.onBubbleRemoved(removed.first, removed.second);
+            }
+            mRemovedBubbles.clear();
+            anythingChanged = true;
+        }
+
+        if (mUpdatedBubble != null) {
+            mListener.onBubbleUpdated(mUpdatedBubble);
+            mUpdatedBubble = null;
+            anythingChanged = true;
+        }
+
+        if (mOrderChanged) {
+            mListener.onOrderChanged(mBubbles);
+            mOrderChanged = false;
+            anythingChanged = true;
+        }
+
+        if (mExpandedChanged) {
+            mListener.onExpandedChanged(mExpanded);
+            mExpandedChanged = false;
+            anythingChanged = true;
+        }
+
+        if (anythingChanged) {
             mListener.apply();
         }
     }
 
-    private void dispatchOnBubbleAdded(Bubble bubble) {
-        if (mListener != null) {
-            mListener.onBubbleAdded(bubble);
-        }
-    }
-
-    private void dispatchOnBubbleRemoved(Bubble bubble, @DismissReason int reason) {
-        if (mListener != null) {
-            mListener.onBubbleRemoved(bubble, reason);
-        }
-    }
-
-    private void dispatchOnExpandedChanged(boolean expanded) {
-        if (mListener != null) {
-            mListener.onExpandedChanged(expanded);
-        }
-    }
-
-    private void dispatchOnSelectionChanged(@Nullable Bubble bubble) {
-        if (mListener != null) {
-            mListener.onSelectionChanged(bubble);
-        }
-    }
-
-    private void dispatchOnBubbleUpdated(Bubble bubble) {
-        if (mListener != null) {
-            mListener.onBubbleUpdated(bubble);
-        }
-    }
-
-    private void dispatchOnOrderChanged(List<Bubble> bubbles) {
-        if (mListener != null) {
-            mListener.onOrderChanged(bubbles);
-        }
-    }
-
-    private void dispatchShowFlyoutText(Bubble bubble, String text) {
-        if (mListener != null) {
-            mListener.showFlyoutText(bubble, text);
-        }
-    }
-
     /**
      * Requests a change to the selected bubble. Calls {@link Listener#onSelectionChanged} if
      * the value changes.
      *
      * @param bubble the new selected bubble
-     * @return true if the state changed as a result
      */
-    private boolean setSelectedBubbleInternal(@Nullable Bubble bubble) {
+    private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
         if (DEBUG) {
             Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
         }
         if (Objects.equals(bubble, mSelectedBubble)) {
-            return false;
+            return;
         }
         if (bubble != null && !mBubbles.contains(bubble)) {
             Log.e(TAG, "Cannot select bubble which doesn't exist!"
                     + " (" + bubble + ") bubbles=" + mBubbles);
-            return false;
+            return;
         }
         if (mExpanded && bubble != null) {
             bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
         }
         mSelectedBubble = bubble;
-        dispatchOnSelectionChanged(mSelectedBubble);
-        if (!mExpanded || mSelectedBubble == null) {
-            mSelectedBubbleExpandedPosition = -1;
-        }
-        return true;
+        mSelectionChanged = true;
+        return;
     }
 
     /**
@@ -369,37 +400,53 @@
      * the value changes.
      *
      * @param shouldExpand the new requested state
-     * @return true if the state changed as a result
      */
-    private boolean setExpandedInternal(boolean shouldExpand) {
+    private void setExpandedInternal(boolean shouldExpand) {
         if (DEBUG) {
             Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
         }
         if (mExpanded == shouldExpand) {
-            return false;
-        }
-        if (mSelectedBubble != null) {
-            mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
+            return;
         }
         if (shouldExpand) {
             if (mBubbles.isEmpty()) {
                 Log.e(TAG, "Attempt to expand stack when empty!");
-                return false;
+                return;
             }
             if (mSelectedBubble == null) {
                 Log.e(TAG, "Attempt to expand stack without selected bubble!");
-                return false;
+                return;
             }
-        } else {
-            repackAll();
+            mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
+            mOrderChanged |= repackAll();
+        } else if (!mBubbles.isEmpty()) {
+            // Apply ordering and grouping rules from expanded -> collapsed, then save
+            // the result.
+            mOrderChanged |= repackAll();
+            // Save the state which should be returned to when expanded (with no other changes)
+
+            if (mBubbles.indexOf(mSelectedBubble) > 0) {
+                // Move the selected bubble to the top while collapsed.
+                if (!mSelectedBubble.isOngoing() && mBubbles.get(0).isOngoing()) {
+                    // The selected bubble cannot be raised to the first position because
+                    // there is an ongoing bubble there. Instead, force the top ongoing bubble
+                    // to become selected.
+                    setSelectedBubbleInternal(mBubbles.get(0));
+                } else {
+                    // Raise the selected bubble (and it's group) up to the front so the selected
+                    // bubble remains on top.
+                    mBubbles.remove(mSelectedBubble);
+                    mBubbles.add(0, mSelectedBubble);
+                    packGroup(0);
+                }
+            }
         }
         mExpanded = shouldExpand;
-        dispatchOnExpandedChanged(mExpanded);
-        return true;
+        mExpandedChanged = true;
     }
 
     private static long sortKey(Bubble bubble) {
-        long key = bubble.getLastActivity();
+        long key = bubble.getLastUpdateTime();
         if (bubble.isOngoing()) {
             // Set 2nd highest bit (signed long int), to partition between ongoing and regular
             key |= 0x4000000000000000L;
@@ -456,8 +503,9 @@
      * unchanged. Relative order of any other bubbles are also unchanged.
      *
      * @param position the position of the first bubble for the group
+     * @return true if the position of any bubbles has changed as a result
      */
-    private void packGroup(int position) {
+    private boolean packGroup(int position) {
         if (DEBUG) {
             Log.d(TAG, "packGroup: position=" + position);
         }
@@ -471,16 +519,27 @@
                 moving.add(0, mBubbles.get(i));
             }
         }
+        if (moving.isEmpty()) {
+            return false;
+        }
         mBubbles.removeAll(moving);
         mBubbles.addAll(position + 1, moving);
+        return true;
     }
 
-    private void repackAll() {
+    /**
+     * This applies a full sort and group pass to all existing bubbles. The bubbles are grouped
+     * by groupId. Each group is then sorted by the max(lastUpdated) time of it's bubbles. Bubbles
+     * within each group are then sorted by lastUpdated descending.
+     *
+     * @return true if the position of any bubbles changed as a result
+     */
+    private boolean repackAll() {
         if (DEBUG) {
             Log.d(TAG, "repackAll()");
         }
         if (mBubbles.isEmpty()) {
-            return;
+            return false;
         }
         Map<String, Long> groupLastActivity = new HashMap<>();
         for (Bubble bubble : mBubbles) {
@@ -494,7 +553,7 @@
         // Sort groups by their most recently active bubble
         List<String> groupsByMostRecentActivity =
                 groupLastActivity.entrySet().stream()
-                        .sorted(GROUPS_BY_LAST_ACTIVITY_DESCENDING)
+                        .sorted(GROUPS_BY_MAX_SORT_KEY_DESCENDING)
                         .map(Map.Entry::getKey)
                         .collect(toList());
 
@@ -504,10 +563,14 @@
         for (String appId : groupsByMostRecentActivity) {
             mBubbles.stream()
                     .filter((b) -> b.getGroupId().equals(appId))
-                    .sorted(BUBBLES_BY_LAST_ACTIVITY_DESCENDING)
+                    .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
                     .forEachOrdered(repacked::add);
         }
+        if (repacked.equals(mBubbles)) {
+            return false;
+        }
         mBubbles = repacked;
+        return true;
     }
 
     private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
@@ -527,21 +590,25 @@
     }
 
     private void onBubbleBlocked(NotificationEntry entry) {
-        boolean changed = false;
-        final String blockedPackage = entry.notification.getPackageName();
+        final String blockedGroupId = Bubble.groupId(entry);
+        int selectedIndex = mBubbles.indexOf(mSelectedBubble);
         for (Iterator<Bubble> i = mBubbles.iterator(); i.hasNext(); ) {
             Bubble bubble = i.next();
-            if (bubble.getPackageName().equals(blockedPackage)) {
+            if (bubble.getGroupId().equals(blockedGroupId)) {
+                mRemovedBubbles.add(Pair.create(bubble, BubbleController.DISMISS_BLOCKED));
                 i.remove();
-                // TODO: handle removal of selected bubble, and collapse safely if emptied (see
-                //  dismissAll)
-                dispatchOnBubbleRemoved(bubble, BubbleController.DISMISS_BLOCKED);
-                changed = true;
             }
         }
-        if (changed) {
-            dispatchApply();
+        if (mBubbles.isEmpty()) {
+            setExpandedInternal(false);
+            setSelectedBubbleInternal(null);
+        } else if (!mBubbles.contains(mSelectedBubble)) {
+            // choose a new one
+            int newIndex = Math.min(selectedIndex, mBubbles.size() - 1);
+            Bubble newSelected = mBubbles.get(newIndex);
+            setSelectedBubbleInternal(newSelected);
         }
+        dispatchPendingChanges();
     }
 
     private int indexForKey(String key) {
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
index 123d73d..35dc177 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -549,6 +549,7 @@
         mBubbleContainer.addView(bubble.iconView, 0,
                 new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
         ViewClippingUtil.setClippingDeactivated(bubble.iconView, true, mClippingParameters);
+        animateInFlyoutForBubble(bubble);
         requestUpdate();
         logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__POSTED);
     }
@@ -570,10 +571,19 @@
 
     // via BubbleData.Listener
     void updateBubble(Bubble bubble) {
+        animateInFlyoutForBubble(bubble);
         requestUpdate();
         logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__UPDATED);
     }
 
+    public void updateBubbleOrder(List<Bubble> bubbles) {
+        for (int i = 0; i < bubbles.size(); i++) {
+            Bubble bubble = bubbles.get(i);
+            mBubbleContainer.moveViewTo(bubble.iconView, i);
+        }
+    }
+
+
     /**
      * Changes the currently selected bubble. If the stack is already expanded, the newly selected
      * bubble will be shown immediately. This does not change the expanded state or change the
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 2d697e3..35a1516 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
@@ -296,22 +296,22 @@
         BubbleStackView stackView = mBubbleController.getStackView();
         mBubbleController.expandStack();
         assertTrue(mBubbleController.isStackExpanded());
-        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
+        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
 
-        // First added is the one that is expanded
-        assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
-        assertFalse(mRow.getEntry().showInShadeWhenBubble());
-
-        // Switch which bubble is expanded
-        mBubbleController.selectBubble(mRow2.getEntry().key);
-        stackView.setExpandedBubble(mRow2.getEntry());
+        // Last added is the one that is expanded
         assertEquals(mRow2.getEntry(), stackView.getExpandedBubbleView().getEntry());
         assertFalse(mRow2.getEntry().showInShadeWhenBubble());
 
+        // Switch which bubble is expanded
+        mBubbleController.selectBubble(mRow.getEntry().key);
+        stackView.setExpandedBubble(mRow.getEntry());
+        assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
+        assertFalse(mRow.getEntry().showInShadeWhenBubble());
+
         // collapse for previous bubble
-        verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().key);
+        verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().key);
         // expand for selected bubble
-        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
+        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
 
         // Collapse
         mBubbleController.collapseStack();
@@ -352,27 +352,27 @@
         mBubbleController.expandStack();
 
         assertTrue(mBubbleController.isStackExpanded());
-        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
+        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
 
-        // First added is the one that is expanded
-        assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
-        assertFalse(mRow.getEntry().showInShadeWhenBubble());
+        // Last added is the one that is expanded
+        assertEquals(mRow2.getEntry(), stackView.getExpandedBubbleView().getEntry());
+        assertFalse(mRow2.getEntry().showInShadeWhenBubble());
 
         // Dismiss currently expanded
         mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
                 BubbleController.DISMISS_USER_GESTURE);
-        verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().key);
+        verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().key);
 
-        // Make sure next bubble is selected
-        assertEquals(mRow2.getEntry(), stackView.getExpandedBubbleView().getEntry());
-        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().key);
+        // Make sure first bubble is selected
+        assertEquals(mRow.getEntry(), stackView.getExpandedBubbleView().getEntry());
+        verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
 
         // Dismiss that one
         mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
                 BubbleController.DISMISS_USER_GESTURE);
 
         // Make sure state changes and collapse happens
-        verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().key);
+        verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().key);
         verify(mBubbleStateChangeListener).onHasBubblesChanged(false);
         assertFalse(mBubbleController.hasBubbles());
     }
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 d6dac2f..33b2e6e 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java
@@ -16,12 +16,18 @@
 
 package com.android.systemui.bubbles;
 
+import static com.android.systemui.bubbles.BubbleController.DISMISS_AGED;
+
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -48,6 +54,8 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.util.List;
+
 @SmallTest
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
@@ -108,6 +116,628 @@
         // Used by BubbleData to set lastAccessedTime
         when(mTimeSource.currentTimeMillis()).thenReturn(1000L);
         mBubbleData.setTimeSource(mTimeSource);
+
+        // Assert baseline starting state
+        assertThat(mBubbleData.hasBubbles()).isFalse();
+        assertThat(mBubbleData.isExpanded()).isFalse();
+        assertThat(mBubbleData.getSelectedBubble()).isNull();
+    }
+
+    @Test
+    public void testAddBubble() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+
+        // Verify
+        verify(mListener).onBubbleAdded(eq(mBubbleA1));
+        verify(mListener).onSelectionChanged(eq(mBubbleA1));
+        verify(mListener).apply();
+    }
+
+    @Test
+    public void testRemoveBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
+
+        // Verify
+        verify(mListener).onBubbleRemoved(eq(mBubbleA1), eq(BubbleController.DISMISS_USER_GESTURE));
+        verify(mListener).apply();
+    }
+
+    // COLLAPSED / ADD
+
+    /**
+     * Verifies that the number of bubbles is not allowed to exceed the maximum. The limit is
+     * enforced by expiring the bubble which was least recently updated (lowest timestamp).
+     */
+    @Test
+    public void test_collapsed_addBubble_atMaxBubbles_expiresOldest() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryA3, 3000);
+        sendUpdatedEntryAtTime(mEntryB1, 4000);
+        sendUpdatedEntryAtTime(mEntryB2, 5000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryC1, 6000);
+        verify(mListener).onBubbleRemoved(eq(mBubbleA1), eq(DISMISS_AGED));
+    }
+
+    /**
+     * Verifies that new bubbles insert to the left when collapsed, carrying along grouped bubbles.
+     * <p>
+     * Placement within the list is based on lastUpdate (post time of the notification), descending
+     * order (with most recent first).
+     *
+     * @see #test_expanded_addBubble_sortAndGrouping_newGroup()
+     * @see #test_expanded_addBubble_sortAndGrouping_existingGroup()
+     */
+    @Test
+    public void test_collapsed_addBubble_sortAndGrouping() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        verify(mListener, never()).onOrderChanged(anyList());
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB1, mBubbleA1)));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB2, mBubbleB1, mBubbleA1)));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryA2, 4000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1)));
+    }
+
+    /**
+     * Verifies that new bubbles insert to the left when collapsed, carrying along grouped bubbles.
+     * Additionally, any bubble which is ongoing is considered "newer" than any non-ongoing bubble.
+     * <p>
+     * Because of the ongoing bubble, the new bubble cannot be placed in the first position. This
+     * causes the 'B' group to remain last, despite having a new button added.
+     *
+     * @see #test_expanded_addBubble_sortAndGrouping_newGroup()
+     * @see #test_expanded_addBubble_sortAndGrouping_existingGroup()
+     */
+    @Test
+    public void test_collapsed_addBubble_sortAndGrouping_withOngoing() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        setOngoing(mEntryA1, true);
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        verify(mListener, never()).onOrderChanged(anyList());
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        verify(mListener, never()).onOrderChanged(eq(listOf(mBubbleA1, mBubbleB1)));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleA1, mBubbleB2, mBubbleB1)));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryA2, 4000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleA1, mBubbleA2, mBubbleB2, mBubbleB1)));
+    }
+
+    /**
+     * Verifies that new bubbles become the selected bubble when they appear when the stack is in
+     * the collapsed state.
+     *
+     * @see #test_collapsed_updateBubble_selectionChanges()
+     * @see #test_collapsed_updateBubble_noSelectionChanges_withOngoing()
+     */
+    @Test
+    public void test_collapsed_addBubble_selectionChanges() {
+        // Setup
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        verify(mListener).onSelectionChanged(eq(mBubbleA1));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        verify(mListener).onSelectionChanged(eq(mBubbleB1));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        verify(mListener).onSelectionChanged(eq(mBubbleB2));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryA2, 4000);
+        verify(mListener).onSelectionChanged(eq(mBubbleA2));
+    }
+    /**
+     * Verifies that while collapsed, the selection will not change if the selected bubble is
+     * ongoing. It remains the top bubble and as such remains selected.
+     *
+     * @see #test_collapsed_addBubble_selectionChanges()
+     */
+    @Test
+    public void test_collapsed_addBubble_noSelectionChanges_withOngoing() {
+        // Setup
+        setOngoing(mEntryA1, true);
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000);
+        verify(mListener, never()).onSelectionChanged(any(Bubble.class));
+        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1); // selection unchanged
+    }
+
+    // COLLAPSED / REMOVE
+
+    /**
+     * Verifies that groups may reorder when bubbles are removed, while the stack is in the
+     * collapsed state.
+     */
+    @Test
+    public void test_collapsed_removeBubble_sortAndGrouping() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, A1, B2, B1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA2, BubbleController.DISMISS_USER_GESTURE);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB2, mBubbleB1, mBubbleA1)));
+    }
+
+
+    /**
+     * Verifies that onOrderChanged is not called when a bubble is removed if the removal does not
+     * cause other bubbles to change position.
+     */
+    @Test
+    public void test_collapsed_removeOldestBubble_doesNotCallOnOrderChanged() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, A1, B2, B1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryB1, BubbleController.DISMISS_USER_GESTURE);
+        verify(mListener, never()).onOrderChanged(anyList());
+    }
+
+    /**
+     * Verifies that bubble ordering reverts to normal when an ongoing bubble is removed. A group
+     * which has a newer bubble may move to the front after the ongoing bubble is removed.
+     */
+    @Test
+    public void test_collapsed_removeBubble_sortAndGrouping_withOngoing() {
+        // Setup
+        setOngoing(mEntryA1, true);
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000); // [A1*, A2, B2, B1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_NOTIF_CANCEL);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB2, mBubbleB1, mBubbleA2)));
+    }
+
+    /**
+     * 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.
+     */
+    @Test
+    public void test_collapsed_removeBubble_selectionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, A1, B2, B1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA2, BubbleController.DISMISS_NOTIF_CANCEL);
+        verify(mListener).onSelectionChanged(eq(mBubbleB2));
+    }
+
+    // COLLAPSED / UPDATE
+
+    /**
+     * Verifies that bubble and group ordering may change with updates while the stack is in the
+     * collapsed state.
+     */
+    @Test
+    public void test_collapsed_updateBubble_orderAndGrouping() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, A1, B2, B1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryB1, 5000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB1, mBubbleB2, mBubbleA2, mBubbleA1)));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryA1, 6000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleA1, mBubbleA2, mBubbleB1, mBubbleB2)));
+    }
+
+    /**
+     * Verifies that selection tracks the most recently updated bubble while in the collapsed state.
+     */
+    @Test
+    public void test_collapsed_updateBubble_selectionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, A1, B2, B1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryB1, 5000);
+        verify(mListener).onSelectionChanged(eq(mBubbleB1));
+
+        reset(mListener);
+        sendUpdatedEntryAtTime(mEntryA1, 6000);
+        verify(mListener).onSelectionChanged(eq(mBubbleA1));
+    }
+
+    /**
+     * Verifies that selection does not change in response to updates when collapsed, if the
+     * selected bubble is ongoing.
+     */
+    @Test
+    public void test_collapsed_updateBubble_noSelectionChanges_withOngoing() {
+        // Setup
+        setOngoing(mEntryA1, true);
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryB2, 3000);
+        sendUpdatedEntryAtTime(mEntryA2, 4000); // [A1*, A2, B2, B1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryB2, 5000); // [A1*, A2, B2, B1]
+        verify(mListener, never()).onSelectionChanged(any(Bubble.class));
+    }
+
+    /**
+     * Verifies that a request to expand the stack has no effect if there are no bubbles.
+     */
+    @Test
+    public void test_collapsed_expansion_whenEmpty_doesNothing() {
+        assertThat(mBubbleData.hasBubbles()).isFalse();
+        changeExpandedStateAtTime(true, 2000L);
+
+        verify(mListener, never()).onExpandedChanged(anyBoolean());
+        verify(mListener, never()).apply();
+    }
+
+    @Test
+    public void test_collapsed_removeLastBubble_clearsSelectedBubble() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
+
+        // Verify the selection was cleared.
+        verify(mListener).onSelectionChanged(isNull());
+    }
+
+    // EXPANDED / ADD
+
+    /**
+     * Verifies that bubbles added as part of a new group insert before existing groups while
+     * expanded.
+     * <p>
+     * Placement within the list is based on lastUpdate (post time of the notification), descending
+     * order (with most recent first).
+     *
+     * @see #test_collapsed_addBubble_sortAndGrouping()
+     * @see #test_expanded_addBubble_sortAndGrouping_existingGroup()
+     */
+    @Test
+    public void test_expanded_addBubble_sortAndGrouping_newGroup() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000); // [B1, A2, A1]
+        changeExpandedStateAtTime(true, 4000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryC1, 4000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleC1, mBubbleB1, mBubbleA2, mBubbleA1)));
+    }
+
+    /**
+     * Verifies that bubbles added as part of a new group insert before existing groups while
+     * expanded, but not before any groups with ongoing bubbles.
+     *
+     * @see #test_collapsed_addBubble_sortAndGrouping_withOngoing()
+     * @see #test_expanded_addBubble_sortAndGrouping_existingGroup()
+     */
+    @Test
+    public void test_expanded_addBubble_sortAndGrouping_newGroup_withOngoing() {
+        // Setup
+        setOngoing(mEntryA1, true);
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000); // [A1*, A2, B1]
+        changeExpandedStateAtTime(true, 4000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryC1, 4000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleA1, mBubbleA2, mBubbleC1, mBubbleB1)));
+    }
+
+    /**
+     * Verifies that bubbles added as part of an existing group insert to the beginning of that
+     * group. The order of groups within the list must not change while in the expanded state.
+     *
+     * @see #test_collapsed_addBubble_sortAndGrouping()
+     * @see #test_expanded_addBubble_sortAndGrouping_newGroup()
+     */
+    @Test
+    public void test_expanded_addBubble_sortAndGrouping_existingGroup() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000); // [B1, A2, A1]
+        changeExpandedStateAtTime(true, 4000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA3, 4000);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB1, mBubbleA3, mBubbleA2, mBubbleA1)));
+    }
+
+    // EXPANDED / UPDATE
+
+    /**
+     * Verifies that updates to bubbles while expanded do not result in any change to sorting
+     * or grouping of bubbles or sorting of groups.
+     *
+     * @see #test_collapsed_addBubble_sortAndGrouping()
+     * @see #test_expanded_addBubble_sortAndGrouping_existingGroup()
+     */
+    @Test
+    public void test_expanded_updateBubble_sortAndGrouping_noChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, B1, A2, A1]
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 4000);
+        verify(mListener, never()).onOrderChanged(anyList());
+    }
+
+    /**
+     * Verifies that updates to bubbles while expanded do not result in any change to selection.
+     *
+     * @see #test_collapsed_addBubble_selectionChanges()
+     * @see #test_collapsed_updateBubble_noSelectionChanges_withOngoing()
+     */
+    @Test
+    public void test_expanded_updateBubble_noSelectionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryA2, 2000);
+        sendUpdatedEntryAtTime(mEntryB1, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, B1, A2, A1]
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        sendUpdatedEntryAtTime(mEntryA1, 6000);
+        sendUpdatedEntryAtTime(mEntryA2, 7000);
+        sendUpdatedEntryAtTime(mEntryB1, 8000);
+        verify(mListener, never()).onSelectionChanged(any(Bubble.class));
+    }
+
+    // EXPANDED / REMOVE
+
+    /**
+     * Verifies that removing a bubble while expanded does not result in reordering of groups
+     * or any of the remaining bubbles.
+     *
+     * @see #test_collapsed_addBubble_sortAndGrouping()
+     * @see #test_expanded_addBubble_sortAndGrouping_existingGroup()
+     */
+    @Test
+    public void test_expanded_removeBubble_sortAndGrouping() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, B1, A2, A1]
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryB2, BubbleController.DISMISS_USER_GESTURE);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB1, mBubbleA2, mBubbleA1)));
+    }
+
+    /**
+     * Verifies that removing the selected bubble while expanded causes another bubble to become
+     * selected. The replacement selection is the bubble which appears at the same index as the
+     * previous one, or the previous index if this was the last position.
+     *
+     * @see #test_collapsed_addBubble_sortAndGrouping()
+     * @see #test_expanded_addBubble_sortAndGrouping_existingGroup()
+     */
+    @Test
+    public void test_expanded_removeBubble_selectionChanges_whenSelectedRemoved() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000);
+        changeExpandedStateAtTime(true, 5000L);
+        mBubbleData.setSelectedBubble(mBubbleA2);  // [B2, B1, ^A2, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA2, BubbleController.DISMISS_USER_GESTURE);
+        verify(mListener).onSelectionChanged(mBubbleA1);
+
+        reset(mListener);
+        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
+        verify(mListener).onSelectionChanged(mBubbleB1);
+    }
+
+    @Test
+    public void test_expandAndCollapse_callsOnExpandedChanged() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        changeExpandedStateAtTime(true, 3000L);
+        verify(mListener).onExpandedChanged(eq(true));
+
+        reset(mListener);
+        changeExpandedStateAtTime(false, 4000L);
+        verify(mListener).onExpandedChanged(eq(false));
+    }
+
+    /**
+     * Verifies that transitions between the collapsed and expanded state maintain sorting and
+     * grouping rules.
+     * <p>
+     * While collapsing, sorting is applied since no sorting happens while expanded. The resulting
+     * state is the new expanded ordering. This state is saved and restored if possible when next
+     * expanded.
+     * <p>
+     * When the stack transitions to the collapsed state, the selected bubble is brought to the top.
+     * Bubbles within the same group should move up with it.
+     * <p>
+     * When the stack transitions back to the expanded state, the previous ordering is restored, as
+     * long as no changes have been made (adds, removes or updates) while in the collapsed state.
+     */
+    @Test
+    public void test_expansionChanges() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000);
+        changeExpandedStateAtTime(true, 5000L); // [B2=4000, B1=2000, A2=3000, A1=1000]
+        sendUpdatedEntryAtTime(mEntryB1, 6000); // [B2=4000, B1=6000*, A2=3000, A1=1000]
+        setCurrentTime(7000);
+        mBubbleData.setSelectedBubble(mBubbleA2);
+        mBubbleData.setListener(mListener);
+        assertThat(mBubbleData.getBubbles()).isEqualTo(
+                listOf(mBubbleB2, mBubbleB1, mBubbleA2, mBubbleA1));
+
+        // Test
+
+        // At this point, B1 has been updated but sorting has not been changed because the
+        // stack is expanded. When next collapsed, sorting will be applied and saved, just prior
+        // to moving the selected bubble to the top (first).
+        //
+        // In this case, the expected re-expand state will be: [B1, B2, A2*, A1]
+        //
+        // That state is restored as long as no changes occur (add/remove/update) while in
+        // the collapsed state.
+        //
+        // collapse -> selected bubble (A2) moves first.
+        changeExpandedStateAtTime(false, 8000L);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleA2, mBubbleA1, mBubbleB1, mBubbleB2)));
+
+        // expand -> "original" order/grouping restored
+        reset(mListener);
+        changeExpandedStateAtTime(true, 10000L);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleB1, mBubbleB2, mBubbleA2, mBubbleA1)));
+    }
+
+    /**
+     * When a change occurs while collapsed (any update, add, remove), the previous expanded
+     * order and grouping becomes invalidated, and the order and grouping when next expanded will
+     * remain the same as collapsed.
+     */
+    @Test
+    public void test_expansionChanges_withUpdatesWhileCollapsed() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        sendUpdatedEntryAtTime(mEntryB1, 2000);
+        sendUpdatedEntryAtTime(mEntryA2, 3000);
+        sendUpdatedEntryAtTime(mEntryB2, 4000);
+        changeExpandedStateAtTime(true, 5000L); // [B2=4000, B1=2000, A2=3000, A1=1000]
+        sendUpdatedEntryAtTime(mEntryB1, 6000); // [B2=4000, B1=*6000, A2=3000, A1=1000]
+        setCurrentTime(7000);
+        mBubbleData.setSelectedBubble(mBubbleA2); // [B2, B1, ^A2, A1]
+        mBubbleData.setListener(mListener);
+
+        // Test
+
+        // At this point, B1 has been updated but sorting has not been changed because the
+        // stack is expanded. When next collapsed, sorting will be applied and saved, just prior
+        // to moving the selected bubble to the top (first).
+        //
+        // In this case, the expected re-expand state will be: [B1, B2, A2*, A1]
+        //
+        // That state is restored as long as no changes occur (add/remove/update) while in
+        // the collapsed state.
+        //
+        // collapse -> selected bubble (A2) moves first.
+        changeExpandedStateAtTime(false, 8000L);
+        verify(mListener).onOrderChanged(eq(listOf(mBubbleA2, mBubbleA1, mBubbleB1, mBubbleB2)));
+
+        // An update occurs, which causes sorting, and this invalidates the previously saved order.
+        sendUpdatedEntryAtTime(mEntryA2, 9000);
+
+        // No order changes when expanding because the new sorted order remains.
+        reset(mListener);
+        changeExpandedStateAtTime(true, 10000L);
+        verify(mListener, never()).onOrderChanged(anyList());
+    }
+
+    @Test
+    public void test_expanded_removeLastBubble_collapsesStack() {
+        // Setup
+        sendUpdatedEntryAtTime(mEntryA1, 1000);
+        changeExpandedStateAtTime(true, 2000);
+        mBubbleData.setListener(mListener);
+
+        // Test
+        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
+        verify(mListener).onExpandedChanged(eq(false));
     }
 
     private NotificationEntry createBubbleEntry(int userId, String notifKey, String packageName) {
@@ -155,553 +785,22 @@
         return new NotificationEntry(sbn);
     }
 
+    private void setCurrentTime(long time) {
+        when(mTimeSource.currentTimeMillis()).thenReturn(time);
+    }
+
     private void sendUpdatedEntryAtTime(NotificationEntry entry, long postTime) {
         setPostTime(entry, postTime);
         mBubbleData.notificationEntryUpdated(entry);
     }
 
     private void changeExpandedStateAtTime(boolean shouldBeExpanded, long time) {
-        when(mTimeSource.currentTimeMillis()).thenReturn(time);
+        setCurrentTime(time);
         mBubbleData.setExpanded(shouldBeExpanded);
     }
 
-    @Test
-    public void testAddBubble() {
-        // Setup
-        mBubbleData.setListener(mListener);
-
-        // Test
-        setPostTime(mEntryA1, 1000);
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-
-        // Verify
-        verify(mListener).onBubbleAdded(eq(mBubbleA1));
-        verify(mListener).onSelectionChanged(eq(mBubbleA1));
-        verify(mListener).apply();
-    }
-
-    @Test
-    public void testRemoveBubble() {
-        // Setup
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-        mBubbleData.notificationEntryUpdated(mEntryA2);
-        mBubbleData.notificationEntryUpdated(mEntryA3);
-        mBubbleData.setListener(mListener);
-
-        // Test
-        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
-
-        // Verify
-        verify(mListener).onBubbleRemoved(eq(mBubbleA1), eq(BubbleController.DISMISS_USER_GESTURE));
-        verify(mListener).onSelectionChanged(eq(mBubbleA2));
-        verify(mListener).apply();
-    }
-
-    @Test
-    public void test_collapsed_addBubble_atMaxBubbles_expiresLeastActive() {
-        // Given
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryA2, 2000);
-        sendUpdatedEntryAtTime(mEntryA3, 3000);
-        sendUpdatedEntryAtTime(mEntryB1, 4000);
-        sendUpdatedEntryAtTime(mEntryB2, 5000);
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-
-        // When
-        sendUpdatedEntryAtTime(mEntryC1, 6000);
-
-        // Then
-        // A2 is removed. A1 is oldest but is the selected bubble.
-        assertThat(mBubbleData.getBubbles()).doesNotContain(mBubbleA2);
-    }
-
-    @Test
-    public void test_collapsed_expand_whenEmpty_doesNothing() {
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        changeExpandedStateAtTime(true, 2000L);
-
-        verify(mListener, never()).onExpandedChanged(anyBoolean());
-        verify(mListener, never()).apply();
-    }
-
-    // New bubble while stack is collapsed
-    @Test
-    public void test_collapsed_addBubble() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        // When
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryB1, 2000);
-        sendUpdatedEntryAtTime(mEntryB2, 3000);
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-
-        // Then
-        // New bubbles move to front when collapsed, bringing bubbles from the same app along
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-    }
-
-    // New bubble while collapsed with ongoing bubble present
-    @Test
-    public void test_collapsed_addBubble_withOngoing() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        // When
-        setOngoing(mEntryA1, true);
-        setPostTime(mEntryA1, 1000);
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-        setPostTime(mEntryB1, 2000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-        setPostTime(mEntryB2, 3000);
-        mBubbleData.notificationEntryUpdated(mEntryB2);
-        setPostTime(mEntryA2, 4000);
-        mBubbleData.notificationEntryUpdated(mEntryA2);
-
-        // Then
-        // New bubbles move to front, but stay behind any ongoing bubbles.
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA1, mBubbleA2, mBubbleB2, mBubbleB1));
-    }
-
-    // Remove the selected bubble (middle bubble), while the stack is collapsed.
-    @Test
-    public void test_collapsed_removeBubble_selected() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        setPostTime(mEntryA1, 1000);
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-
-        setPostTime(mEntryB1, 2000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-
-        setPostTime(mEntryB2, 3000);
-        mBubbleData.notificationEntryUpdated(mEntryB2);
-
-        setPostTime(mEntryA2, 4000);
-        mBubbleData.notificationEntryUpdated(mEntryA2);
-
-        mBubbleData.setSelectedBubble(mBubbleB2);
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        // When
-        mBubbleData.notificationEntryRemoved(mEntryB2, BubbleController.DISMISS_USER_GESTURE);
-
-        // Then
-        // (Selection remains in the same position)
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleB1);
-    }
-
-    // Remove the selected bubble (last bubble), while the stack is collapsed.
-    @Test
-    public void test_collapsed_removeSelectedBubble_inLastPosition() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryB1, 2000);
-        sendUpdatedEntryAtTime(mEntryB2, 3000);
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-
-        mBubbleData.setSelectedBubble(mBubbleB1);
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        // When
-        mBubbleData.notificationEntryRemoved(mEntryB1, BubbleController.DISMISS_USER_GESTURE);
-
-        // Then
-        // (Selection is forced to move to previous)
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleB2);
-    }
-
-    @Test
-    public void test_collapsed_addBubble_ongoing() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        // When
-        setPostTime(mEntryA1, 1000);
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-
-        setPostTime(mEntryB1, 2000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-
-        setPostTime(mEntryB2, 3000);
-        setOngoing(mEntryB2, true);
-        mBubbleData.notificationEntryUpdated(mEntryB2);
-
-        setPostTime(mEntryA2, 4000);
-        mBubbleData.notificationEntryUpdated(mEntryA2);
-
-        // Then
-        // New bubbles move to front, but stay behind any ongoing bubbles.
-        // Does not break grouping. (A2 is inserted after B1, even though it's newer).
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA2, mBubbleA1));
-    }
-
-    @Test
-    public void test_collapsed_removeBubble() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryB1, 2000);
-        sendUpdatedEntryAtTime(mEntryB2, 3000);
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-
-        // When
-        mBubbleData.notificationEntryRemoved(mEntryB2, BubbleController.DISMISS_USER_GESTURE);
-
-        // Then
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB1));
-    }
-
-    @Test
-    public void test_collapsed_updateBubble() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryB1, 2000);
-        sendUpdatedEntryAtTime(mEntryB2, 3000);
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        // When
-        sendUpdatedEntryAtTime(mEntryB2, 5000);
-
-        // Then
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA2, mBubbleA1));
-    }
-
-    @Test
-    public void test_collapsed_updateBubble_withOngoing() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        setPostTime(mEntryA1, 1000);
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-
-        setPostTime(mEntryB1, 2000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-
-        setPostTime(mEntryB2, 3000);
-        mBubbleData.notificationEntryUpdated(mEntryB2);
-
-        setOngoing(mEntryA2, true);
-        setPostTime(mEntryA2, 4000);
-        mBubbleData.notificationEntryUpdated(mEntryA2);
-
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        // When
-        setPostTime(mEntryB1, 5000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-
-        // Then
-        // A2 remains in first position, due to being ongoing. B1 moves before B2, Group A
-        // remains before group B.
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB1, mBubbleB2));
-    }
-
-    @Test
-    public void test_collapse_afterUpdateWhileExpanded() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryB1, 2000);
-        sendUpdatedEntryAtTime(mEntryB2, 3000);
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-
-        changeExpandedStateAtTime(true, 5000L);
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        sendUpdatedEntryAtTime(mEntryB1, 6000);
-
-        // (No reordering while expanded)
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        // When
-        changeExpandedStateAtTime(false, 7000L);
-
-        // Then
-        // A1 moves to front on collapse, since it is the selected bubble (and most recently
-        // accessed).
-        // A2 moves next to A1 to maintain grouping.
-        // B1 moves in front of B2, since it received an update while expanded
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA1, mBubbleA2, mBubbleB1, mBubbleB2));
-    }
-
-    @Test
-    public void test_collapse_afterUpdateWhileExpanded_withOngoing() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryB1, 2000);
-
-        setOngoing(mEntryB2, true);
-        sendUpdatedEntryAtTime(mEntryB2, 3000);
-
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-
-        changeExpandedStateAtTime(true, 5000L);
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA2, mBubbleA1));
-
-        sendUpdatedEntryAtTime(mEntryA1, 6000);
-
-        // No reordering if expanded
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA2, mBubbleA1));
-
-        // When
-        changeExpandedStateAtTime(false, 7000L);
-
-        // Then
-        // B2 remains in first position because it is ongoing.
-        // B1 remains grouped with B2
-        // A1 moves in front of A2, since it is more recently updated (and is selected).
-        // B1 moves in front of B2, since it has more recent activity.
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA1, mBubbleA2));
-    }
-
-    @Test
-    public void test_collapsed_removeLastBubble_clearsSelectedBubble() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryB1, 2000);
-        sendUpdatedEntryAtTime(mEntryB2, 3000);
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-
-        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
-        mBubbleData.notificationEntryRemoved(mEntryB1, BubbleController.DISMISS_USER_GESTURE);
-        mBubbleData.notificationEntryRemoved(mEntryB2, BubbleController.DISMISS_USER_GESTURE);
-        mBubbleData.notificationEntryRemoved(mEntryA2, BubbleController.DISMISS_USER_GESTURE);
-
-        assertThat(mBubbleData.getSelectedBubble()).isNull();
-    }
-
-    @Test
-    public void test_expanded_addBubble_atMaxBubbles_expiresLeastActive() {
-        // Given
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-
-        changeExpandedStateAtTime(true, 2000L);
-        assertThat(mBubbleData.getSelectedBubble().getLastActivity()).isEqualTo(2000);
-
-        sendUpdatedEntryAtTime(mEntryA2, 3000);
-        sendUpdatedEntryAtTime(mEntryA3, 4000);
-        sendUpdatedEntryAtTime(mEntryB1, 5000);
-        sendUpdatedEntryAtTime(mEntryB2, 6000);
-        sendUpdatedEntryAtTime(mEntryB3, 7000);
-
-
-        // Then
-        // A1 would be removed, but it is selected and expanded, so it should not go away.
-        // Instead, fall through to removing A2 (the next oldest).
-        assertThat(mBubbleData.getBubbles()).doesNotContain(mEntryA2);
-    }
-
-    @Test
-    public void test_expanded_removeLastBubble_collapsesStack() {
-        // Given
-        setPostTime(mEntryA1, 1000);
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-
-        setPostTime(mEntryB1, 2000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-
-        setPostTime(mEntryB2, 3000);
-        mBubbleData.notificationEntryUpdated(mEntryC1);
-
-        mBubbleData.setExpanded(true);
-
-        mBubbleData.notificationEntryRemoved(mEntryA1, BubbleController.DISMISS_USER_GESTURE);
-        mBubbleData.notificationEntryRemoved(mEntryB1, BubbleController.DISMISS_USER_GESTURE);
-        mBubbleData.notificationEntryRemoved(mEntryC1, BubbleController.DISMISS_USER_GESTURE);
-
-        assertThat(mBubbleData.isExpanded()).isFalse();
-        assertThat(mBubbleData.getSelectedBubble()).isNull();
-    }
-
-    // Bubbles do not reorder while expanded
-    @Test
-    public void test_expanded_selection_collapseToTop() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryA2, 2000);
-        sendUpdatedEntryAtTime(mEntryB1, 3000);
-
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB1, mBubbleA2, mBubbleA1));
-
-        changeExpandedStateAtTime(true, 4000L);
-
-        // regrouping only happens when collapsed (after new or update) or expanded->collapsed
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB1, mBubbleA2, mBubbleA1));
-
-        changeExpandedStateAtTime(false, 6000L);
-
-        // A1 is still selected and it's lastAccessed time has been updated
-        // on collapse, sorting is applied, keeping the selected bubble at the front
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA1, mBubbleA2, mBubbleB1));
-    }
-
-    // New bubble from new app while stack is expanded
-    @Test
-    public void test_expanded_addBubble_newApp() {
-        // Given
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryA2, 2000);
-        sendUpdatedEntryAtTime(mEntryA3, 3000);
-        sendUpdatedEntryAtTime(mEntryB1, 4000);
-        sendUpdatedEntryAtTime(mEntryB2, 5000);
-
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-
-        changeExpandedStateAtTime(true, 6000L);
-
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA1);
-        assertThat(mBubbleData.getSelectedBubble().getLastActivity()).isEqualTo(6000L);
-
-        // regrouping only happens when collapsed (after new or update) or expanded->collapsed
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA3, mBubbleA2, mBubbleA1));
-
-        // When
-        sendUpdatedEntryAtTime(mEntryC1, 7000);
-
-        // Then
-        // A2 is expired. A1 was oldest, but lastActivityTime is reset when expanded, since A1 is
-        // selected.
-        // C1 is added at the end since bubbles are expanded.
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA3, mBubbleA1, mBubbleC1));
-    }
-
-    // New bubble from existing app while stack is expanded
-    @Test
-    public void test_expanded_addBubble_existingApp() {
-        // Given
-        sendUpdatedEntryAtTime(mEntryB1, 1000);
-        sendUpdatedEntryAtTime(mEntryB2, 2000);
-        sendUpdatedEntryAtTime(mEntryA1, 3000);
-        sendUpdatedEntryAtTime(mEntryA2, 4000);
-        sendUpdatedEntryAtTime(mEntryA3, 5000);
-
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleB1);
-
-        changeExpandedStateAtTime(true, 6000L);
-
-        // B1 is first (newest, since it's just been expanded and is selected)
-        assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleB1);
-        assertThat(mBubbleData.getSelectedBubble().getLastActivity()).isEqualTo(6000L);
-
-        // regrouping only happens when collapsed (after new or update) or while collapsing
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA3, mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        // When
-        sendUpdatedEntryAtTime(mEntryB3, 7000);
-
-        // Then
-        // (B2 is expired, B1 was oldest, but it's lastActivityTime is updated at the point when
-        // the stack was expanded, since it is the selected bubble.
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA3, mBubbleA2, mBubbleA1, mBubbleB3, mBubbleB1));
-    }
-
-    // Updated bubble from existing app while stack is expanded
-    @Test
-    public void test_expanded_updateBubble_existingApp() {
-        sendUpdatedEntryAtTime(mEntryA1, 1000);
-        sendUpdatedEntryAtTime(mEntryA2, 2000);
-        sendUpdatedEntryAtTime(mEntryB1, 3000);
-        sendUpdatedEntryAtTime(mEntryB2, 4000);
-
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA2, mBubbleA1));
-        mBubbleData.setExpanded(true);
-
-        sendUpdatedEntryAtTime(mEntryA1, 5000);
-
-        // Does not reorder while expanded (for an update).
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleB2, mBubbleB1, mBubbleA2, mBubbleA1));
-    }
-
-    @Test
-    public void test_expanded_updateBubble() {
-        // Given
-        assertThat(mBubbleData.hasBubbles()).isFalse();
-        assertThat(mBubbleData.isExpanded()).isFalse();
-
-        setPostTime(mEntryA1, 1000);
-        mBubbleData.notificationEntryUpdated(mEntryA1);
-
-        setPostTime(mEntryB1, 2000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-
-        setPostTime(mEntryB2, 3000);
-        mBubbleData.notificationEntryUpdated(mEntryB2);
-
-        setPostTime(mEntryA2, 4000);
-        mBubbleData.notificationEntryUpdated(mEntryA2);
-
-        mBubbleData.setExpanded(true);
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
-
-        // When
-        setPostTime(mEntryB1, 5000);
-        mBubbleData.notificationEntryUpdated(mEntryB1);
-
-        // Then
-        // B1 remains in the same place due to being expanded
-        assertThat(mBubbleData.getBubbles()).isEqualTo(
-                ImmutableList.of(mBubbleA2, mBubbleA1, mBubbleB2, mBubbleB1));
+    /** Syntactic sugar to keep assertions more readable */
+    private static <T> List<T> listOf(T... a) {
+        return ImmutableList.copyOf(a);
     }
 }