BubbleData [7/n]: BubbleData impl and tests!!

This adds the full implementation of BubbleData along with
test cases developed against the designed behavior of Bubbles.

See: go/bubble-order for the full policy and rules behind this.

Since BubbleData.Listener#onOrderChanged is not yet connected,
the behavior is not yet applied to UI. This will happen in a
following change (along with new BubbleStackView tests).

Bug: 123542488
Test: atest BubbleControllerTest BubbleDataTest
Change-Id: I79d4452c196945735f081d6ae0fdc673de7ae102
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
index 0332477..7094d28 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
@@ -16,9 +16,12 @@
 package com.android.systemui.bubbles;
 
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
 import android.os.UserHandle;
 import android.view.LayoutInflater;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.systemui.R;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 
@@ -40,15 +43,24 @@
     public NotificationEntry entry;
     BubbleView iconView;
     BubbleExpandedView expandedView;
+    private long mLastUpdated;
+    private long mLastAccessed;
 
     private static String groupId(NotificationEntry entry) {
         UserHandle user = entry.notification.getUser();
-        return user.getIdentifier() + '|' + entry.notification.getPackageName();
+        return user.getIdentifier() + "|" + entry.notification.getPackageName();
+    }
+
+    /** Used in tests when no UI is required. */
+    @VisibleForTesting(visibility = PRIVATE)
+    Bubble(NotificationEntry e) {
+        this (e, null);
     }
 
     Bubble(NotificationEntry e, BubbleExpandedView.OnBubbleBlockedListener listener) {
         entry = e;
         mKey = e.key;
+        mLastUpdated = e.notification.getPostTime();
         mGroupId = groupId(e);
         mListener = listener;
     }
@@ -101,12 +113,37 @@
 
     void setEntry(NotificationEntry entry) {
         this.entry = entry;
+        mLastUpdated = entry.notification.getPostTime();
         if (mInflated) {
             iconView.update(entry);
             expandedView.update(entry);
         }
     }
 
+    public long getLastActivity() {
+        return Math.max(mLastUpdated, mLastAccessed);
+    }
+
+    /**
+     * Should be invoked whenever a Bubble is accessed (selected while expanded).
+     */
+    void markAsAccessedAt(long lastAccessedMillis) {
+        mLastAccessed = lastAccessedMillis;
+        entry.setShowInShadeWhenBubble(false);
+    }
+
+    /**
+     * @return whether bubble is from a notification associated with a foreground service.
+     */
+    public boolean isOngoing() {
+        return entry.isForegroundService();
+    }
+
+    @Override
+    public String toString() {
+        return "Bubble{" + mKey + '}';
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index ef383ad..d071363 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -41,6 +41,7 @@
 import android.os.ServiceManager;
 import android.provider.Settings;
 import android.service.notification.StatusBarNotification;
+import android.util.Log;
 import android.view.Display;
 import android.view.IPinnedStackController;
 import android.view.IPinnedStackListener;
@@ -83,6 +84,7 @@
 public class BubbleController implements ConfigurationController.ConfigurationListener {
 
     private static final String TAG = "BubbleController";
+    private static final boolean DEBUG = true;
 
     @Retention(SOURCE)
     @IntDef({DISMISS_USER_GESTURE, DISMISS_AGED, DISMISS_TASK_FINISHED, DISMISS_BLOCKED,
@@ -203,6 +205,9 @@
 
         configurationController.addCallback(this /* configurationListener */);
 
+        mBubbleData = data;
+        mBubbleData.setListener(mBubbleDataListener);
+
         mNotificationEntryManager = Dependency.get(NotificationEntryManager.class);
         mNotificationEntryManager.addNotificationEntryListener(mEntryListener);
 
@@ -219,9 +224,6 @@
         } catch (RemoteException e) {
             e.printStackTrace();
         }
-
-        mBubbleData = data;
-        mBubbleData.setListener(mBubbleDataListener);
         mSurfaceSynchronizer = synchronizer;
 
         mBarService = IStatusBarService.Stub.asInterface(
@@ -482,7 +484,7 @@
         }
 
         @Override
-        public void onSelectionChanged(Bubble selectedBubble) {
+        public void onSelectionChanged(@Nullable Bubble selectedBubble) {
             if (mStackView != null) {
                 mStackView.setSelectedBubble(selectedBubble);
             }
@@ -506,6 +508,18 @@
         public void apply() {
             mNotificationEntryManager.updateNotifications();
             updateVisibility();
+
+            if (DEBUG) {
+                Log.d(TAG, "[BubbleData]");
+                Log.d(TAG, formatBubblesString(mBubbleData.getBubbles(),
+                        mBubbleData.getSelectedBubble()));
+
+                if (mStackView != null) {
+                    Log.d(TAG, "[BubbleStackView]");
+                    Log.d(TAG, formatBubblesString(mStackView.getBubblesOnScreen(),
+                            mStackView.getExpandedBubble()));
+                }
+            }
         }
     };
 
@@ -623,6 +637,23 @@
         entry.setShowInShadeWhenBubble(!suppressNotification);
     }
 
+    static String formatBubblesString(List<Bubble> bubbles, Bubble selected) {
+        StringBuilder sb = new StringBuilder();
+        for (Bubble bubble : bubbles) {
+            if (bubble == null) {
+                sb.append("   <null> !!!!!\n");
+            } else {
+                boolean isSelected = (bubble == selected);
+                sb.append(String.format("%s Bubble{act=%12d, ongoing=%d, key=%s}\n",
+                        ((isSelected) ? "->" : "  "),
+                        bubble.getLastActivity(),
+                        (bubble.isOngoing() ? 1 : 0),
+                        bubble.getKey()));
+            }
+        }
+        return sb.toString();
+    }
+
     /**
      * Return true if the applications with the package name is running in foreground.
      *
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
index 259665d..38ba91e 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
@@ -17,21 +17,26 @@
 
 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
 
-import android.app.ActivityManager;
+import static java.util.stream.Collectors.toList;
+
 import android.app.Notification;
 import android.app.PendingIntent;
 import android.content.Context;
 import android.util.Log;
 
+import androidx.annotation.Nullable;
+
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.systemui.bubbles.BubbleController.DismissReason;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 
 import javax.inject.Inject;
@@ -44,6 +49,15 @@
 public class BubbleData {
 
     private static final String TAG = "BubbleData";
+    private static final boolean DEBUG = false;
+
+    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<Map.Entry<String, Long>> GROUPS_BY_LAST_ACTIVITY_DESCENDING =
+            Comparator.<Map.Entry<String, Long>, Long>comparing(Map.Entry::getValue).reversed();
 
     /**
      * This interface reports changes to the state and appearance of bubbles which should be applied
@@ -83,7 +97,7 @@
         void onOrderChanged(List<Bubble> bubbles);
 
         /** Indicates the selected bubble changed. */
-        void onSelectionChanged(Bubble selectedBubble);
+        void onSelectionChanged(@Nullable Bubble selectedBubble);
 
         /**
          * The UI should transition to the given state, incorporating any pending changes during
@@ -98,16 +112,28 @@
         void apply();
     }
 
+    interface TimeSource {
+        long currentTimeMillis();
+    }
+
     private final Context mContext;
-    private final List<Bubble> mBubbles = new ArrayList<>();
+    private List<Bubble> mBubbles;
     private Bubble mSelectedBubble;
     private boolean mExpanded;
+
+    // TODO: ensure this is invalidated at the appropriate time
+    private int mSelectedBubbleExpandedPosition = -1;
+
+    private TimeSource mTimeSource = System::currentTimeMillis;
+
+    @Nullable
     private Listener mListener;
 
     @VisibleForTesting
     @Inject
     public BubbleData(Context context) {
         mContext = context;
+        mBubbles = new ArrayList<>();
     }
 
     public boolean hasBubbles() {
@@ -122,29 +148,41 @@
         return getBubbleWithKey(key) != null;
     }
 
+    @Nullable
+    public Bubble getSelectedBubble() {
+        return mSelectedBubble;
+    }
+
     public void setExpanded(boolean expanded) {
         if (setExpandedInternal(expanded)) {
-            mListener.apply();
+            dispatchApply();
         }
     }
 
     public void setSelectedBubble(Bubble bubble) {
+        if (DEBUG) {
+            Log.d(TAG, "setSelectedBubble: " + bubble);
+        }
         if (setSelectedBubbleInternal(bubble)) {
-            mListener.apply();
+            dispatchApply();
         }
     }
 
     public void notificationEntryUpdated(NotificationEntry entry) {
+        if (DEBUG) {
+            Log.d(TAG, "notificationEntryUpdated: " + entry);
+        }
         Bubble bubble = getBubbleWithKey(entry.key);
         if (bubble == null) {
             // Create a new bubble
             bubble = new Bubble(entry, this::onBubbleBlocked);
-            mBubbles.add(0, bubble); // TODO: reorder/group
-            mListener.onBubbleAdded(bubble);
+            doAdd(bubble);
+            dispatchOnBubbleAdded(bubble);
         } else {
             // Updates an existing bubble
             bubble.setEntry(entry);
-            mListener.onBubbleUpdated(bubble);
+            doUpdate(bubble);
+            dispatchOnBubbleUpdated(bubble);
         }
         if (shouldAutoExpand(entry)) {
             setSelectedBubbleInternal(bubble);
@@ -154,49 +192,148 @@
         } else if (mSelectedBubble == null) {
             setSelectedBubbleInternal(bubble);
         }
-        // TODO: reorder/group
-        mListener.apply();
+        dispatchApply();
+    }
+
+    private void doAdd(Bubble bubble) {
+        if (DEBUG) {
+            Log.d(TAG, "doAdd: " + bubble);
+        }
+        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());
+        }
+        insertBubble(minInsertPoint, bubble);
+        if (!isExpanded()) {
+            packGroup(findFirstIndexForGroup(bubble.getGroupId()));
+        }
+        if (mBubbles.size() > MAX_BUBBLES) {
+            mBubbles.stream()
+                    // sort oldest first (ascending lastActivity)
+                    .sorted(Comparator.comparingLong(Bubble::getLastActivity))
+                    // skip the selected bubble
+                    .filter((b) -> !b.equals(mSelectedBubble))
+                    .findFirst()
+                    .ifPresent((b) -> {
+                        doRemove(b.getKey(), BubbleController.DISMISS_AGED);
+                        dispatchApply();
+                    });
+        }
+    }
+
+    private void doUpdate(Bubble bubble) {
+        if (DEBUG) {
+            Log.d(TAG, "doUpdate: " + bubble);
+        }
+        if (!isExpanded()) {
+            // while collapsed, update causes re-sort
+            mBubbles.remove(bubble);
+            insertBubble(0, bubble);
+            packGroup(findFirstIndexForGroup(bubble.getGroupId()));
+        }
     }
 
     public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
-        int indexToRemove = indexForKey(entry.key);
-        if (indexToRemove >= 0) {
-            Bubble removed = mBubbles.remove(indexToRemove);
-            removed.setDismissed();
-            mListener.onBubbleRemoved(removed, reason);
-            maybeSendDeleteIntent(reason, removed.entry);
+        if (DEBUG) {
+            Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
+        }
+        doRemove(entry.key, reason);
+        dispatchApply();
+    }
 
-            if (mBubbles.isEmpty()) {
+    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);
-            } else if (removed == mSelectedBubble) {
+            }
+            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);
             }
-            // TODO: reorder/group
-            mListener.apply();
+            bubbleToRemove.setDismissed();
+            maybeSendDeleteIntent(reason, bubbleToRemove.entry);
         }
     }
 
     public void dismissAll(@DismissReason int reason) {
-        boolean changed = setExpandedInternal(false);
+        if (DEBUG) {
+            Log.d(TAG, "dismissAll: reason=" + reason);
+        }
+        if (mBubbles.isEmpty()) {
+            return;
+        }
+        setExpandedInternal(false);
+        setSelectedBubbleInternal(null);
         while (!mBubbles.isEmpty()) {
             Bubble bubble = mBubbles.remove(0);
             bubble.setDismissed();
             maybeSendDeleteIntent(reason, bubble.entry);
-            mListener.onBubbleRemoved(bubble, reason);
-            changed = true;
+            dispatchOnBubbleRemoved(bubble, reason);
         }
-        if (setSelectedBubbleInternal(null)) {
-            changed = true;
-        }
-        if (changed) {
-            // TODO: reorder/group
+        dispatchApply();
+    }
+
+    private void dispatchApply() {
+        if (mListener != null) {
             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.
@@ -204,7 +341,10 @@
      * @param bubble the new selected bubble
      * @return true if the state changed as a result
      */
-    private boolean setSelectedBubbleInternal(Bubble bubble) {
+    private boolean setSelectedBubbleInternal(@Nullable Bubble bubble) {
+        if (DEBUG) {
+            Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
+        }
         if (Objects.equals(bubble, mSelectedBubble)) {
             return false;
         }
@@ -213,16 +353,17 @@
                     + " (" + bubble + ") bubbles=" + mBubbles);
             return false;
         }
-        if (mExpanded) {
-            // TODO: bubble.markAsActive() ?
-            bubble.entry.setShowInShadeWhenBubble(false);
+        if (mExpanded && bubble != null) {
+            mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
         }
         mSelectedBubble = bubble;
-        mListener.onSelectionChanged(mSelectedBubble);
+        dispatchOnSelectionChanged(mSelectedBubble);
+        if (!mExpanded || mSelectedBubble == null) {
+            mSelectedBubbleExpandedPosition = -1;
+        }
         return true;
     }
 
-
     /**
      * Requests a change to the expanded state. Calls {@link Listener#onExpandedChanged} if
      * the value changes.
@@ -231,9 +372,15 @@
      * @return true if the state changed as a result
      */
     private boolean setExpandedInternal(boolean shouldExpand) {
+        if (DEBUG) {
+            Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
+        }
         if (mExpanded == shouldExpand) {
             return false;
         }
+        if (mSelectedBubble != null) {
+            mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
+        }
         if (shouldExpand) {
             if (mBubbles.isEmpty()) {
                 Log.e(TAG, "Attempt to expand stack when empty!");
@@ -243,15 +390,126 @@
                 Log.e(TAG, "Attempt to expand stack without selected bubble!");
                 return false;
             }
-            // TODO: bubble.markAsActive() ?
-            mSelectedBubble.entry.setShowInShadeWhenBubble(false);
+        } else {
+            repackAll();
         }
-        // TODO: reorder/regroup
         mExpanded = shouldExpand;
-        mListener.onExpandedChanged(mExpanded);
+        dispatchOnExpandedChanged(mExpanded);
         return true;
     }
 
+    private static long sortKey(Bubble bubble) {
+        long key = bubble.getLastActivity();
+        if (bubble.isOngoing()) {
+            // Set 2nd highest bit (signed long int), to partition between ongoing and regular
+            key |= 0x4000000000000000L;
+        }
+        return key;
+    }
+
+    /**
+     * Locates and inserts the bubble into a sorted position. The is inserted
+     * based on sort key, groupId is not considered. A call to {@link #packGroup(int)} may be
+     * required to keep grouping intact.
+     *
+     * @param minPosition the first insert point to consider
+     * @param newBubble the bubble to insert
+     * @return the position where the bubble was inserted
+     */
+    private int insertBubble(int minPosition, Bubble newBubble) {
+        long newBubbleSortKey = sortKey(newBubble);
+        String previousGroupId = null;
+
+        for (int pos = minPosition; pos < mBubbles.size(); pos++) {
+            Bubble bubbleAtPos = mBubbles.get(pos);
+            String groupIdAtPos = bubbleAtPos.getGroupId();
+            boolean atStartOfGroup = !groupIdAtPos.equals(previousGroupId);
+
+            if (atStartOfGroup && newBubbleSortKey > sortKey(bubbleAtPos)) {
+                // Insert before the start of first group which has older bubbles.
+                mBubbles.add(pos, newBubble);
+                return pos;
+            }
+            previousGroupId = groupIdAtPos;
+        }
+        mBubbles.add(newBubble);
+        return mBubbles.size() - 1;
+    }
+
+    private boolean hasBubbleWithGroupId(String groupId) {
+        return mBubbles.stream().anyMatch(b -> b.getGroupId().equals(groupId));
+    }
+
+    private int findFirstIndexForGroup(String appId) {
+        for (int i = 0; i < mBubbles.size(); i++) {
+            Bubble bubbleAtPos = mBubbles.get(i);
+            if (bubbleAtPos.getGroupId().equals(appId)) {
+                return i;
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * Starting at the given position, moves all bubbles with the same group id to follow. Bubbles
+     * at positions lower than {@code position} are unchanged. Relative order within the group
+     * unchanged. Relative order of any other bubbles are also unchanged.
+     *
+     * @param position the position of the first bubble for the group
+     */
+    private void packGroup(int position) {
+        if (DEBUG) {
+            Log.d(TAG, "packGroup: position=" + position);
+        }
+        Bubble groupStart = mBubbles.get(position);
+        final String groupAppId = groupStart.getGroupId();
+        List<Bubble> moving = new ArrayList<>();
+
+        // Walk backward, collect bubbles within the group
+        for (int i = mBubbles.size() - 1; i > position; i--) {
+            if (mBubbles.get(i).getGroupId().equals(groupAppId)) {
+                moving.add(0, mBubbles.get(i));
+            }
+        }
+        mBubbles.removeAll(moving);
+        mBubbles.addAll(position + 1, moving);
+    }
+
+    private void repackAll() {
+        if (DEBUG) {
+            Log.d(TAG, "repackAll()");
+        }
+        if (mBubbles.isEmpty()) {
+            return;
+        }
+        Map<String, Long> groupLastActivity = new HashMap<>();
+        for (Bubble bubble : mBubbles) {
+            long maxSortKeyForGroup = groupLastActivity.getOrDefault(bubble.getGroupId(), 0L);
+            long sortKeyForBubble = sortKey(bubble);
+            if (sortKeyForBubble > maxSortKeyForGroup) {
+                groupLastActivity.put(bubble.getGroupId(), sortKeyForBubble);
+            }
+        }
+
+        // Sort groups by their most recently active bubble
+        List<String> groupsByMostRecentActivity =
+                groupLastActivity.entrySet().stream()
+                        .sorted(GROUPS_BY_LAST_ACTIVITY_DESCENDING)
+                        .map(Map.Entry::getKey)
+                        .collect(toList());
+
+        List<Bubble> repacked = new ArrayList<>(mBubbles.size());
+
+        // For each group, add bubbles, freshest to oldest
+        for (String appId : groupsByMostRecentActivity) {
+            mBubbles.stream()
+                    .filter((b) -> b.getGroupId().equals(appId))
+                    .sorted(BUBBLES_BY_LAST_ACTIVITY_DESCENDING)
+                    .forEachOrdered(repacked::add);
+        }
+        mBubbles = repacked;
+    }
+
     private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
         if (reason == BubbleController.DISMISS_USER_GESTURE) {
             Notification.BubbleMetadata bubbleMetadata = entry.getBubbleMetadata();
@@ -275,13 +533,14 @@
             Bubble bubble = i.next();
             if (bubble.getPackageName().equals(blockedPackage)) {
                 i.remove();
-                mListener.onBubbleRemoved(bubble, BubbleController.DISMISS_BLOCKED);
+                // TODO: handle removal of selected bubble, and collapse safely if emptied (see
+                //  dismissAll)
+                dispatchOnBubbleRemoved(bubble, BubbleController.DISMISS_BLOCKED);
                 changed = true;
             }
         }
         if (changed) {
-            // TODO: reorder/group
-            mListener.apply();
+            dispatchApply();
         }
     }
 
@@ -295,24 +554,11 @@
         return -1;
     }
 
-    private Bubble removeBubbleWithKey(String key) {
-        for (int i = 0; i < mBubbles.size(); i++) {
-            Bubble bubble = mBubbles.get(i);
-            if (bubble.getKey().equals(key)) {
-                mBubbles.remove(i);
-                return bubble;
-            }
-        }
-        return null;
-    }
-
     /**
      * The set of bubbles.
-     *
-     * @deprecated
      */
-    @Deprecated
-    public Collection<Bubble> getBubbles() {
+    @VisibleForTesting(visibility = PRIVATE)
+    public List<Bubble> getBubbles() {
         return Collections.unmodifiableList(mBubbles);
     }
 
@@ -327,6 +573,11 @@
         return null;
     }
 
+    @VisibleForTesting(visibility = PRIVATE)
+    void setTimeSource(TimeSource timeSource) {
+        mTimeSource = timeSource;
+    }
+
     public void setListener(Listener listener) {
         mListener = listener;
     }
@@ -334,17 +585,6 @@
     boolean shouldAutoExpand(NotificationEntry entry) {
         Notification.BubbleMetadata metadata = entry.getBubbleMetadata();
         return metadata != null && metadata.getAutoExpandBubble()
-                && isForegroundApp(entry.notification.getPackageName());
-    }
-
-    /**
-     * Return true if the applications with the package name is running in foreground.
-     *
-     * @param pkgName application package name.
-     */
-    boolean isForegroundApp(String pkgName) {
-        ActivityManager am = mContext.getSystemService(ActivityManager.class);
-        List<ActivityManager.RunningTaskInfo> tasks = am.getRunningTasks(1 /* maxNum */);
-        return !tasks.isEmpty() && pkgName.equals(tasks.get(0).topActivity.getPackageName());
+                && BubbleController.isForegroundApp(mContext, entry.notification.getPackageName());
     }
 }
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
index d9fe47a..ad4419c 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -60,6 +60,7 @@
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
@@ -211,7 +212,7 @@
 
         mBubbleData = data;
         mInflater = LayoutInflater.from(context);
-        mTouchHandler = new BubbleTouchHandler(context, this);
+        mTouchHandler = new BubbleTouchHandler(this, data, context);
         setOnTouchListener(mTouchHandler);
         mInflater = LayoutInflater.from(context);
 
@@ -484,6 +485,9 @@
 
     // via BubbleData.Listener
     void addBubble(Bubble bubble) {
+        if (DEBUG) {
+            Log.d(TAG, "addBubble: " + bubble);
+        }
         bubble.inflate(mInflater, this);
         mBubbleContainer.addView(bubble.iconView, 0,
                 new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
@@ -494,10 +498,17 @@
 
     // via BubbleData.Listener
     void removeBubble(Bubble bubble) {
+        if (DEBUG) {
+            Log.d(TAG, "removeBubble: " + bubble);
+        }
         // Remove it from the views
         int removedIndex = mBubbleContainer.indexOfChild(bubble.iconView);
-        mBubbleContainer.removeViewAt(removedIndex);
-        logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED);
+        if (removedIndex >= 0) {
+            mBubbleContainer.removeViewAt(removedIndex);
+            logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED);
+        } else {
+            Log.d(TAG, "was asked to remove Bubble, but didn't find the view! " + bubble);
+        }
     }
 
     // via BubbleData.Listener
@@ -512,7 +523,10 @@
      * position of any bubble.
      */
     // via BubbleData.Listener
-    public void setSelectedBubble(Bubble bubbleToSelect) {
+    public void setSelectedBubble(@Nullable Bubble bubbleToSelect) {
+        if (DEBUG) {
+            Log.d(TAG, "setSelectedBubble: " + bubbleToSelect);
+        }
         if (mExpandedBubble != null && mExpandedBubble.equals(bubbleToSelect)) {
             return;
         }
@@ -543,6 +557,9 @@
      */
     // via BubbleData.Listener
     public void setExpanded(boolean shouldExpand) {
+        if (DEBUG) {
+            Log.d(TAG, "setExpanded: " + shouldExpand);
+        }
         boolean wasExpanded = mIsExpanded;
         if (shouldExpand == wasExpanded) {
             return;
@@ -567,6 +584,9 @@
      */
     @Deprecated
     void stackDismissed(int reason) {
+        if (DEBUG) {
+            Log.d(TAG, "stackDismissed: reason=" + reason);
+        }
         mBubbleData.dismissAll(reason);
         logBubbleEvent(null /* no bubble associated with bubble stack dismiss */,
                 StatsLog.BUBBLE_UICHANGED__ACTION__STACK_DISMISSED);
@@ -614,6 +634,9 @@
     @Deprecated
     @MainThread
     void collapseStack() {
+        if (DEBUG) {
+            Log.d(TAG, "collapseStack()");
+        }
         mBubbleData.setExpanded(false);
     }
 
@@ -623,6 +646,9 @@
     @Deprecated
     @MainThread
     void collapseStack(Runnable endRunnable) {
+        if (DEBUG) {
+            Log.d(TAG, "collapseStack(endRunnable)");
+        }
         collapseStack();
         // TODO - use the runnable at end of animation
         endRunnable.run();
@@ -638,6 +664,9 @@
     @Deprecated
     @MainThread
     void expandStack() {
+        if (DEBUG) {
+            Log.d(TAG, "expandStack()");
+        }
         mBubbleData.setExpanded(true);
     }
 
@@ -645,6 +674,9 @@
      * Tell the stack to animate to collapsed or expanded state.
      */
     private void animateExpansion(boolean shouldExpand) {
+        if (DEBUG) {
+            Log.d(TAG, "animateExpansion: shouldExpand=" + shouldExpand);
+        }
         if (mIsExpanded != shouldExpand) {
             hideFlyoutImmediate();
 
@@ -726,6 +758,9 @@
 
     /** Called when a drag operation on an individual bubble has started. */
     public void onBubbleDragStart(View bubble) {
+        if (DEBUG) {
+            Log.d(TAG, "onBubbleDragStart: bubble=" + bubble);
+        }
         mExpandedAnimationController.prepareForBubbleDrag(bubble);
     }
 
@@ -741,6 +776,9 @@
     /** Called when a drag operation on an individual bubble has finished. */
     public void onBubbleDragFinish(
             View bubble, float x, float y, float velX, float velY, boolean dismissed) {
+        if (DEBUG) {
+            Log.d(TAG, "onBubbleDragFinish: bubble=" + bubble + ", dismissed=" + dismissed);
+        }
         if (!mIsExpanded || mIsExpansionAnimating) {
             return;
         }
@@ -753,6 +791,9 @@
     }
 
     void onDragStart() {
+        if (DEBUG) {
+            Log.d(TAG, "onDragStart()");
+        }
         if (mIsExpanded || mIsExpansionAnimating) {
             return;
         }
@@ -773,6 +814,9 @@
     }
 
     void onDragFinish(float x, float y, float velX, float velY) {
+        if (DEBUG) {
+            Log.d(TAG, "onDragFinish");
+        }
         // TODO: Add fling to bottom to dismiss.
         mIsDragging = false;
 
@@ -914,6 +958,9 @@
     }
 
     private void updateExpandedBubble() {
+        if (DEBUG) {
+            Log.d(TAG, "updateExpandedBubble()");
+        }
         mExpandedViewContainer.removeAllViews();
         if (mExpandedBubble != null && mIsExpanded) {
             mExpandedViewContainer.addView(mExpandedBubble.expandedView);
@@ -924,7 +971,9 @@
     }
 
     private void applyCurrentState() {
-        Log.d(TAG, "applyCurrentState: mIsExpanded=" + mIsExpanded);
+        if (DEBUG) {
+            Log.d(TAG, "applyCurrentState: mIsExpanded=" + mIsExpanded);
+        }
         mExpandedViewContainer.setVisibility(mIsExpanded ? VISIBLE : GONE);
         if (mIsExpanded) {
             // First update the view so that it calculates a new height (ensuring the y position
@@ -963,10 +1012,14 @@
     }
 
     private void updatePointerPosition() {
-        if (mExpandedBubble != null) {
-            float pointerPosition = mExpandedBubble.iconView.getTranslationX()
-                    + (mExpandedBubble.iconView.getWidth() / 2f);
-            mExpandedBubble.expandedView.setPointerPosition((int) pointerPosition);
+        if (DEBUG) {
+            Log.d(TAG, "updatePointerPosition()");
+        }
+        Bubble expandedBubble = getExpandedBubble();
+        if (expandedBubble != null) {
+            BubbleView iconView = expandedBubble.iconView;
+            float pointerPosition = iconView.getTranslationX() + (iconView.getWidth() / 2f);
+            expandedBubble.expandedView.setPointerPosition((int) pointerPosition);
         }
     }
 
@@ -1062,4 +1115,18 @@
         }
         return mExpandedBubble.expandedView.performBackPressIfNeeded();
     }
+
+    /** For debugging only */
+    List<Bubble> getBubblesOnScreen() {
+        List<Bubble> bubbles = new ArrayList<>();
+        for (int i = 0; i < mBubbleContainer.getChildCount(); i++) {
+            View child = mBubbleContainer.getChildAt(i);
+            if (child instanceof BubbleView) {
+                String key = ((BubbleView) child).getKey();
+                Bubble bubble = mBubbleData.getBubbleWithKey(key);
+                bubbles.add(bubble);
+            }
+        }
+        return bubbles;
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
index a51d46c..82e6279 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
@@ -37,9 +37,12 @@
     /** Velocity required to dismiss a bubble without dragging it into the dismiss target. */
     private static final float DISMISS_MIN_VELOCITY = 4000f;
 
+    private static final String TAG = "BubbleTouchHandler";
+
     private final PointF mTouchDown = new PointF();
     private final PointF mViewPositionOnTouchDown = new PointF();
     private final BubbleStackView mStack;
+    private final BubbleData mBubbleData;
 
     private BubbleController mController = Dependency.get(BubbleController.class);
     private PipDismissViewController mDismissViewController;
@@ -60,10 +63,12 @@
     /** View that was initially touched, when we received the first ACTION_DOWN event. */
     private View mTouchedView;
 
-    BubbleTouchHandler(Context context, BubbleStackView stackView) {
+    BubbleTouchHandler(BubbleStackView stackView,
+            BubbleData bubbleData, Context context) {
         final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
         mTouchSlopSquared = touchSlop * touchSlop;
         mDismissViewController = new PipDismissViewController(context);
+        mBubbleData = bubbleData;
         mStack = stackView;
     }
 
@@ -80,7 +85,7 @@
         // If this is an ACTION_OUTSIDE event, or the stack reported that we aren't touching
         // anything, collapse the stack.
         if (action == MotionEvent.ACTION_OUTSIDE || mTouchedView == null) {
-            mStack.collapseStack();
+            mBubbleData.setExpanded(false);
             resetForNextGesture();
             return false;
         }
@@ -151,8 +156,8 @@
                     mStack.onDragFinishAsDismiss();
                 } else if (isFlyout) {
                     // TODO(b/129768381): Expand if tapped, dismiss if swiped away.
-                    if (!mStack.isExpanded() && !mMovedEnough) {
-                        mStack.expandStack();
+                    if (!mBubbleData.isExpanded() && !mMovedEnough) {
+                        mBubbleData.setExpanded(true);
                     }
                 } else if (mMovedEnough) {
                     mVelocityTracker.computeCurrentVelocity(/* maxVelocity */ 1000);
@@ -170,15 +175,13 @@
                         }
                     }
                 } else if (mTouchedView == mStack.getExpandedBubbleView()) {
-                    mStack.collapseStack();
+                    mBubbleData.setExpanded(false);
                 } else if (isStack) {
-                    if (mStack.isExpanded()) {
-                        mStack.collapseStack();
-                    } else {
-                        mStack.expandStack();
-                    }
+                    // Toggle expansion
+                    mBubbleData.setExpanded(!mBubbleData.isExpanded());
                 } else {
-                    mStack.setExpandedBubble(((BubbleView) mTouchedView).getKey());
+                    final String key = ((BubbleView) mTouchedView).getKey();
+                    mBubbleData.setSelectedBubble(mBubbleData.getBubbleWithKey(key));
                 }
 
                 resetForNextGesture();
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java
new file mode 100644
index 0000000..d6dac2f
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleDataTest.java
@@ -0,0 +1,707 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.graphics.drawable.Icon;
+import android.os.UserHandle;
+import android.service.notification.StatusBarNotification;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.bubbles.BubbleData.TimeSource;
+import com.android.systemui.statusbar.NotificationTestHelper;
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class BubbleDataTest extends SysuiTestCase {
+
+    private NotificationEntry mEntryA1;
+    private NotificationEntry mEntryA2;
+    private NotificationEntry mEntryA3;
+    private NotificationEntry mEntryB1;
+    private NotificationEntry mEntryB2;
+    private NotificationEntry mEntryB3;
+    private NotificationEntry mEntryC1;
+
+    private Bubble mBubbleA1;
+    private Bubble mBubbleA2;
+    private Bubble mBubbleA3;
+    private Bubble mBubbleB1;
+    private Bubble mBubbleB2;
+    private Bubble mBubbleB3;
+    private Bubble mBubbleC1;
+
+    private BubbleData mBubbleData;
+
+    @Mock
+    private TimeSource mTimeSource;
+    @Mock
+    private BubbleData.Listener mListener;
+    @Mock
+    private PendingIntent mExpandIntent;
+    @Mock
+    private PendingIntent mDeleteIntent;
+
+    private NotificationTestHelper mNotificationTestHelper;
+
+    @Before
+    public void setUp() throws Exception {
+        mNotificationTestHelper = new NotificationTestHelper(mContext);
+        MockitoAnnotations.initMocks(this);
+
+        mEntryA1 = createBubbleEntry(1, "a1", "package.a");
+        mEntryA2 = createBubbleEntry(1, "a2", "package.a");
+        mEntryA3 = createBubbleEntry(1, "a3", "package.a");
+        mEntryB1 = createBubbleEntry(1, "b1", "package.b");
+        mEntryB2 = createBubbleEntry(1, "b2", "package.b");
+        mEntryB3 = createBubbleEntry(1, "b3", "package.b");
+        mEntryC1 = createBubbleEntry(1, "c1", "package.c");
+
+        mBubbleA1 = new Bubble(mEntryA1);
+        mBubbleA2 = new Bubble(mEntryA2);
+        mBubbleA3 = new Bubble(mEntryA3);
+        mBubbleB1 = new Bubble(mEntryB1);
+        mBubbleB2 = new Bubble(mEntryB2);
+        mBubbleB3 = new Bubble(mEntryB3);
+        mBubbleC1 = new Bubble(mEntryC1);
+
+        mBubbleData = new BubbleData(getContext());
+
+        // Used by BubbleData to set lastAccessedTime
+        when(mTimeSource.currentTimeMillis()).thenReturn(1000L);
+        mBubbleData.setTimeSource(mTimeSource);
+    }
+
+    private NotificationEntry createBubbleEntry(int userId, String notifKey, String packageName) {
+        return createBubbleEntry(userId, notifKey, packageName, 1000);
+    }
+
+    private void setPostTime(NotificationEntry entry, long postTime) {
+        when(entry.notification.getPostTime()).thenReturn(postTime);
+    }
+
+    private void setOngoing(NotificationEntry entry, boolean ongoing) {
+        if (ongoing) {
+            entry.notification.getNotification().flags |= Notification.FLAG_FOREGROUND_SERVICE;
+        } else {
+            entry.notification.getNotification().flags &= ~Notification.FLAG_FOREGROUND_SERVICE;
+        }
+    }
+
+    /**
+     * No ExpandableNotificationRow is required to test BubbleData. This setup is all that is
+     * required for BubbleData functionality and verification. NotificationTestHelper is used only
+     * as a convenience to create a Notification w/BubbleMetadata.
+     */
+    private NotificationEntry createBubbleEntry(int userId, String notifKey, String packageName,
+            long postTime) {
+        // BubbleMetadata
+        Notification.BubbleMetadata bubbleMetadata = new Notification.BubbleMetadata.Builder()
+                .setIntent(mExpandIntent)
+                .setDeleteIntent(mDeleteIntent)
+                .setIcon(Icon.createWithResource("", 0))
+                .build();
+        // Notification -> BubbleMetadata
+        Notification notification = mNotificationTestHelper.createNotification(false,
+                null /* groupKey */, bubbleMetadata);
+
+        // StatusBarNotification
+        StatusBarNotification sbn = mock(StatusBarNotification.class);
+        when(sbn.getKey()).thenReturn(notifKey);
+        when(sbn.getUser()).thenReturn(new UserHandle(userId));
+        when(sbn.getPackageName()).thenReturn(packageName);
+        when(sbn.getPostTime()).thenReturn(postTime);
+        when(sbn.getNotification()).thenReturn(notification);
+
+        // NotificationEntry -> StatusBarNotification -> Notification -> BubbleMetadata
+        return new NotificationEntry(sbn);
+    }
+
+    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);
+        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));
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java
index e4b90c5..028fd7a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java
@@ -234,7 +234,7 @@
      * @param bubbleMetadata the bubble metadata to use for this notification if it exists.
      * @return a notification that is in the group specified or standalone if unspecified
      */
-    private Notification createNotification(boolean isGroupSummary,
+    public Notification createNotification(boolean isGroupSummary,
             @Nullable String groupKey, @Nullable BubbleMetadata bubbleMetadata) {
         Notification publicVersion = new Notification.Builder(mContext).setSmallIcon(
                 R.drawable.ic_person)