BubbleData [6/n]: Splice BubbleData into code path

BubbleData internal representation becomes a list in prep
for managing order and grouping internally. Changes are
passed through bubbleData and dispatched for UI updates
via Listener.

Bug: 123542488
Test: atest BubbleControllerTest
Change-Id: I380ca718bc896307b31e73030b4202762a6cf353
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
index 665df77..0332477 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
@@ -16,47 +16,65 @@
 package com.android.systemui.bubbles;
 
 
+import android.os.UserHandle;
 import android.view.LayoutInflater;
 
 import com.android.systemui.R;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 
+import java.util.Objects;
+
 /**
  * Encapsulates the data and UI elements of a bubble.
  */
 class Bubble {
 
+    private static final boolean DEBUG = false;
+    private static final String TAG = "Bubble";
+
     private final String mKey;
+    private final String mGroupId;
     private final BubbleExpandedView.OnBubbleBlockedListener mListener;
 
     private boolean mInflated;
-
-    public BubbleView iconView;
-    public BubbleExpandedView expandedView;
     public NotificationEntry entry;
+    BubbleView iconView;
+    BubbleExpandedView expandedView;
+
+    private static String groupId(NotificationEntry entry) {
+        UserHandle user = entry.notification.getUser();
+        return user.getIdentifier() + '|' + entry.notification.getPackageName();
+    }
 
     Bubble(NotificationEntry e, BubbleExpandedView.OnBubbleBlockedListener listener) {
         entry = e;
         mKey = e.key;
+        mGroupId = groupId(e);
         mListener = listener;
     }
 
-    /** @deprecated use the other constructor to defer View creation. */
-    @Deprecated
-    Bubble(NotificationEntry e, LayoutInflater inflater, BubbleStackView stackView,
-            BubbleExpandedView.OnBubbleBlockedListener listener) {
-        this(e, listener);
-        inflate(inflater, stackView);
-    }
-
     public String getKey() {
         return mKey;
     }
 
+    public String getGroupId() {
+        return mGroupId;
+    }
+
+    public String getPackageName() {
+        return entry.notification.getPackageName();
+    }
+
     boolean isInflated() {
         return mInflated;
     }
 
+    public void updateDotVisibility() {
+        if (iconView != null) {
+            iconView.updateDotVisibility();
+        }
+    }
+
     void inflate(LayoutInflater inflater, BubbleStackView stackView) {
         if (mInflated) {
             return;
@@ -73,10 +91,32 @@
         mInflated = true;
     }
 
+    void setDismissed() {
+        entry.setBubbleDismissed(true);
+        // TODO: move this somewhere where it can be guaranteed not to run until safe from flicker
+        if (expandedView != null) {
+            expandedView.cleanUpExpandedState();
+        }
+    }
+
     void setEntry(NotificationEntry entry) {
+        this.entry = entry;
         if (mInflated) {
             iconView.update(entry);
             expandedView.update(entry);
         }
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof Bubble)) return false;
+        Bubble bubble = (Bubble) o;
+        return Objects.equals(mKey, bubble.mKey);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mKey);
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index 0fcc950..acdcfb2 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -77,8 +77,7 @@
  * The controller manages addition, removal, and visible state of bubbles on screen.
  */
 @Singleton
-public class BubbleController implements BubbleExpandedView.OnBubbleBlockedListener,
-        ConfigurationController.ConfigurationListener {
+public class BubbleController implements ConfigurationController.ConfigurationListener {
 
     private static final String TAG = "BubbleController";
 
@@ -174,6 +173,10 @@
         @Override
         public void onStateChanged(int newState) {
             mState = newState;
+            boolean shouldCollapse = (mState != SHADE);
+            if (shouldCollapse) {
+                collapseStack();
+            }
             updateVisibility();
         }
     }
@@ -236,7 +239,6 @@
             if (mExpandListener != null) {
                 mStackView.setExpandListener(mExpandListener);
             }
-            mStackView.setOnBlockedListener(this);
         }
     }
 
@@ -284,28 +286,38 @@
         if (mStackView == null) {
             return false;
         }
-        for (Bubble bubble : mBubbleData.getBubbles()) {
-            if (!bubble.entry.isBubbleDismissed()) {
-                return true;
-            }
-        }
-        return false;
+        return mBubbleData.hasBubbles();
     }
 
     /**
      * Whether the stack of bubbles is expanded or not.
      */
     public boolean isStackExpanded() {
-        return mStackView != null && mStackView.isExpanded();
+        return mBubbleData.isExpanded();
+    }
+
+    /**
+     * Tell the stack of bubbles to expand.
+     */
+    public void expandStack() {
+        mBubbleData.setExpanded(true);
     }
 
     /**
      * Tell the stack of bubbles to collapse.
      */
     public void collapseStack() {
-        if (mStackView != null) {
-            mStackView.collapseStack();
-        }
+        mBubbleData.setExpanded(false /* expanded */);
+    }
+
+    void selectBubble(Bubble bubble) {
+        mBubbleData.setSelectedBubble(bubble);
+    }
+
+    @VisibleForTesting
+    void selectBubble(String key) {
+        Bubble bubble = mBubbleData.getBubbleWithKey(key);
+        selectBubble(bubble);
     }
 
     /**
@@ -314,8 +326,10 @@
      * @param notificationKey the notification key for the bubble to be selected
      */
     public void expandStackAndSelectBubble(String notificationKey) {
-        if (mStackView != null && mBubbleData.getBubble(notificationKey) != null) {
-            mStackView.setExpandedBubble(notificationKey);
+        Bubble bubble = mBubbleData.getBubbleWithKey(notificationKey);
+        if (bubble != null) {
+            mBubbleData.setSelectedBubble(bubble);
+            mBubbleData.setExpanded(true);
         }
     }
 
@@ -323,13 +337,7 @@
      * Tell the stack of bubbles to be dismissed, this will remove all of the bubbles in the stack.
      */
     void dismissStack(@DismissReason int reason) {
-        if (mStackView == null) {
-            return;
-        }
-        mStackView.stackDismissed(reason);
-
-        updateVisibility();
-        mNotificationEntryManager.updateNotifications();
+        mBubbleData.dismissAll(reason);
     }
 
     /**
@@ -348,20 +356,7 @@
      * @param notif the notification associated with this bubble.
      */
     void updateBubble(NotificationEntry notif) {
-        if (mStackView != null && mBubbleData.getBubble(notif.key) != null) {
-            // It's an update
-            mStackView.updateBubble(notif);
-        } else {
-            // It's new
-            ensureStackViewCreated();
-            mStackView.addBubble(notif);
-        }
-        Bubble bubble = mBubbleData.getBubble(notif.key);
-        if (shouldAutoExpand(notif)) {
-            mStackView.setSelectedBubble(bubble);
-            mStackView.setExpanded(true);
-        }
-        updateVisibility();
+        mBubbleData.notificationEntryUpdated(notif);
     }
 
     /**
@@ -371,23 +366,10 @@
      */
     @MainThread
     void removeBubble(String key, int reason) {
-        if (mStackView != null) {
-            mStackView.removeBubble(key, reason);
-        }
-        mNotificationEntryManager.updateNotifications();
-        updateVisibility();
-    }
-
-    @Override
-    public void onBubbleBlocked(NotificationEntry entry) {
-        Object[] bubbles = mBubbleData.getBubbles().toArray();
-        for (int i = 0; i < bubbles.length; i++) {
-            NotificationEntry e = ((Bubble) bubbles[i]).entry;
-            boolean samePackage = entry.notification.getPackageName().equals(
-                    e.notification.getPackageName());
-            if (samePackage) {
-                removeBubble(entry.key, DISMISS_BLOCKED);
-            }
+        // TEMP: refactor to change this to pass entry
+        Bubble bubble = mBubbleData.getBubbleWithKey(key);
+        if (bubble != null) {
+            mBubbleData.notificationEntryRemoved(bubble.entry, reason);
         }
     }
 
@@ -424,7 +406,6 @@
                 updateShowInShadeForSuppressNotification(entry);
                 entry.setBubbleDismissed(false); // updates come back as bubbles even if dismissed
                 updateBubble(entry);
-                mStackView.updateDotVisibility(entry.key);
             }
         }
 
@@ -446,44 +427,57 @@
         }
     };
 
+    @SuppressWarnings("FieldCanBeLocal")
     private final BubbleData.Listener mBubbleDataListener = new BubbleData.Listener() {
+
         @Override
         public void onBubbleAdded(Bubble bubble) {
-
+            ensureStackViewCreated();
+            mStackView.addBubble(bubble);
         }
 
         @Override
         public void onBubbleRemoved(Bubble bubble, int reason) {
-
+            if (mStackView != null) {
+                mStackView.removeBubble(bubble);
+            }
         }
 
         public void onBubbleUpdated(Bubble bubble) {
-
+            if (mStackView != null) {
+                mStackView.updateBubble(bubble);
+            }
         }
 
         @Override
         public void onOrderChanged(List<Bubble> bubbles) {
-
         }
 
         @Override
         public void onSelectionChanged(Bubble selectedBubble) {
-
+            if (mStackView != null) {
+                mStackView.setSelectedBubble(selectedBubble);
+            }
         }
 
         @Override
         public void onExpandedChanged(boolean expanded) {
-
+            if (mStackView != null) {
+                mStackView.setExpanded(expanded);
+            }
         }
 
         @Override
         public void showFlyoutText(Bubble bubble, String text) {
-
+            if (mStackView != null) {
+                mStackView.animateInFlyoutForBubble(bubble);
+            }
         }
 
         @Override
         public void apply() {
-
+            mNotificationEntryManager.updateNotifications();
+            updateVisibility();
         }
     };
 
@@ -514,7 +508,6 @@
             mStackView.setVisibility(hasBubbles() ? VISIBLE : INVISIBLE);
         } else if (mStackView != null) {
             mStackView.setVisibility(INVISIBLE);
-            collapseStack();
         }
         updateBubblesShowing();
     }
@@ -621,14 +614,14 @@
         @Override
         public void onTaskMovedToFront(RunningTaskInfo taskInfo) {
             if (mStackView != null && taskInfo.displayId == Display.DEFAULT_DISPLAY) {
-                mStackView.collapseStack();
+                mBubbleData.setExpanded(false);
             }
         }
 
         @Override
         public void onActivityLaunchOnSecondaryDisplayRerouted() {
             if (mStackView != null) {
-                mStackView.collapseStack();
+                mBubbleData.setExpanded(false);
             }
         }
     }
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
index fe3f9d1..259665d 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
@@ -15,14 +15,24 @@
  */
 package com.android.systemui.bubbles;
 
-import androidx.annotation.Nullable;
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
+import android.app.ActivityManager;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.util.Log;
 
 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.HashMap;
+import java.util.Collections;
+import java.util.Iterator;
 import java.util.List;
+import java.util.Objects;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -33,6 +43,8 @@
 @Singleton
 public class BubbleData {
 
+    private static final String TAG = "BubbleData";
+
     /**
      * This interface reports changes to the state and appearance of bubbles which should be applied
      * as necessary to the UI.
@@ -53,7 +65,7 @@
          * A Bubble has been removed. A call to {@link #onOrderChanged(List)} will
          * follow.
          */
-        void onBubbleRemoved(Bubble bubble, @BubbleController.DismissReason int reason);
+        void onBubbleRemoved(Bubble bubble, @DismissReason int reason);
 
         /**
          * An existing bubble has been updated.
@@ -86,46 +98,253 @@
         void apply();
     }
 
-    private HashMap<String, Bubble> mBubbles = new HashMap<>();
+    private final Context mContext;
+    private final List<Bubble> mBubbles = new ArrayList<>();
+    private Bubble mSelectedBubble;
+    private boolean mExpanded;
     private Listener mListener;
 
     @VisibleForTesting
     @Inject
-    public BubbleData() {}
-
-    /**
-     * The set of bubbles.
-     */
-    public Collection<Bubble> getBubbles() {
-        return mBubbles.values();
+    public BubbleData(Context context) {
+        mContext = context;
     }
 
-    @Nullable
-    public Bubble getBubble(String key) {
-        return mBubbles.get(key);
+    public boolean hasBubbles() {
+        return !mBubbles.isEmpty();
     }
 
-    public void addBubble(Bubble b) {
-        mBubbles.put(b.getKey(), b);
+    public boolean isExpanded() {
+        return mExpanded;
     }
 
-    @Nullable
-    public Bubble removeBubble(String key) {
-        return mBubbles.remove(key);
+    public boolean hasBubbleWithKey(String key) {
+        return getBubbleWithKey(key) != null;
     }
 
-    public void updateBubble(String key, NotificationEntry newEntry) {
-        Bubble oldBubble = mBubbles.get(key);
-        if (oldBubble != null) {
-            oldBubble.setEntry(newEntry);
+    public void setExpanded(boolean expanded) {
+        if (setExpandedInternal(expanded)) {
+            mListener.apply();
         }
     }
 
-    public void clear() {
-        mBubbles.clear();
+    public void setSelectedBubble(Bubble bubble) {
+        if (setSelectedBubbleInternal(bubble)) {
+            mListener.apply();
+        }
+    }
+
+    public void notificationEntryUpdated(NotificationEntry 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);
+        } else {
+            // Updates an existing bubble
+            bubble.setEntry(entry);
+            mListener.onBubbleUpdated(bubble);
+        }
+        if (shouldAutoExpand(entry)) {
+            setSelectedBubbleInternal(bubble);
+            if (!mExpanded) {
+                setExpandedInternal(true);
+            }
+        } else if (mSelectedBubble == null) {
+            setSelectedBubbleInternal(bubble);
+        }
+        // TODO: reorder/group
+        mListener.apply();
+    }
+
+    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 (mBubbles.isEmpty()) {
+                setExpandedInternal(false);
+                setSelectedBubbleInternal(null);
+            } else if (removed == mSelectedBubble) {
+                int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
+                Bubble newSelected = mBubbles.get(newIndex);
+                setSelectedBubbleInternal(newSelected);
+            }
+            // TODO: reorder/group
+            mListener.apply();
+        }
+    }
+
+    public void dismissAll(@DismissReason int reason) {
+        boolean changed = setExpandedInternal(false);
+        while (!mBubbles.isEmpty()) {
+            Bubble bubble = mBubbles.remove(0);
+            bubble.setDismissed();
+            maybeSendDeleteIntent(reason, bubble.entry);
+            mListener.onBubbleRemoved(bubble, reason);
+            changed = true;
+        }
+        if (setSelectedBubbleInternal(null)) {
+            changed = true;
+        }
+        if (changed) {
+            // TODO: reorder/group
+            mListener.apply();
+        }
+    }
+
+    /**
+     * 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(Bubble bubble) {
+        if (Objects.equals(bubble, mSelectedBubble)) {
+            return false;
+        }
+        if (bubble != null && !mBubbles.contains(bubble)) {
+            Log.e(TAG, "Cannot select bubble which doesn't exist!"
+                    + " (" + bubble + ") bubbles=" + mBubbles);
+            return false;
+        }
+        if (mExpanded) {
+            // TODO: bubble.markAsActive() ?
+            bubble.entry.setShowInShadeWhenBubble(false);
+        }
+        mSelectedBubble = bubble;
+        mListener.onSelectionChanged(mSelectedBubble);
+        return true;
+    }
+
+
+    /**
+     * Requests a change to the expanded state. Calls {@link Listener#onExpandedChanged} if
+     * the value changes.
+     *
+     * @param shouldExpand the new requested state
+     * @return true if the state changed as a result
+     */
+    private boolean setExpandedInternal(boolean shouldExpand) {
+        if (mExpanded == shouldExpand) {
+            return false;
+        }
+        if (shouldExpand) {
+            if (mBubbles.isEmpty()) {
+                Log.e(TAG, "Attempt to expand stack when empty!");
+                return false;
+            }
+            if (mSelectedBubble == null) {
+                Log.e(TAG, "Attempt to expand stack without selected bubble!");
+                return false;
+            }
+            // TODO: bubble.markAsActive() ?
+            mSelectedBubble.entry.setShowInShadeWhenBubble(false);
+        }
+        // TODO: reorder/regroup
+        mExpanded = shouldExpand;
+        mListener.onExpandedChanged(mExpanded);
+        return true;
+    }
+
+    private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
+        if (reason == BubbleController.DISMISS_USER_GESTURE) {
+            Notification.BubbleMetadata bubbleMetadata = entry.getBubbleMetadata();
+            PendingIntent deleteIntent = bubbleMetadata != null
+                    ? bubbleMetadata.getDeleteIntent()
+                    : null;
+            if (deleteIntent != null) {
+                try {
+                    deleteIntent.send();
+                } catch (PendingIntent.CanceledException e) {
+                    Log.w(TAG, "Failed to send delete intent for bubble with key: " + entry.key);
+                }
+            }
+        }
+    }
+
+    private void onBubbleBlocked(NotificationEntry entry) {
+        boolean changed = false;
+        final String blockedPackage = entry.notification.getPackageName();
+        for (Iterator<Bubble> i = mBubbles.iterator(); i.hasNext(); ) {
+            Bubble bubble = i.next();
+            if (bubble.getPackageName().equals(blockedPackage)) {
+                i.remove();
+                mListener.onBubbleRemoved(bubble, BubbleController.DISMISS_BLOCKED);
+                changed = true;
+            }
+        }
+        if (changed) {
+            // TODO: reorder/group
+            mListener.apply();
+        }
+    }
+
+    private int indexForKey(String key) {
+        for (int i = 0; i < mBubbles.size(); i++) {
+            Bubble bubble = mBubbles.get(i);
+            if (bubble.getKey().equals(key)) {
+                return i;
+            }
+        }
+        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() {
+        return Collections.unmodifiableList(mBubbles);
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    Bubble getBubbleWithKey(String key) {
+        for (int i = 0; i < mBubbles.size(); i++) {
+            Bubble bubble = mBubbles.get(i);
+            if (bubble.getKey().equals(key)) {
+                return bubble;
+            }
+        }
+        return null;
     }
 
     public void setListener(Listener listener) {
         mListener = listener;
     }
-}
+
+    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());
+    }
+}
\ 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 424cd55..18b2e37 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -20,8 +20,6 @@
 import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
 
 import android.annotation.NonNull;
-import android.app.Notification;
-import android.app.PendingIntent;
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.Outline;
@@ -54,9 +52,7 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.widget.ViewClippingUtil;
-import com.android.systemui.Dependency;
 import com.android.systemui.R;
-import com.android.systemui.bubbles.BubbleController.DismissReason;
 import com.android.systemui.bubbles.animation.ExpandedAnimationController;
 import com.android.systemui.bubbles.animation.PhysicsAnimationLayout;
 import com.android.systemui.bubbles.animation.StackAnimationController;
@@ -359,14 +355,13 @@
         }
         switch (action) {
             case AccessibilityNodeInfo.ACTION_DISMISS:
-                Dependency.get(BubbleController.class).dismissStack(
-                        BubbleController.DISMISS_ACCESSIBILITY_ACTION);
+                mBubbleData.dismissAll(BubbleController.DISMISS_ACCESSIBILITY_ACTION);
                 return true;
             case AccessibilityNodeInfo.ACTION_COLLAPSE:
-                collapseStack();
+                mBubbleData.setExpanded(false);
                 return true;
             case AccessibilityNodeInfo.ACTION_EXPAND:
-                expandStack();
+                mBubbleData.setExpanded(true);
                 return true;
         }
         return false;
@@ -393,9 +388,9 @@
      * @param key the {@link NotificationEntry#key} associated with the bubble.
      */
     public void updateDotVisibility(String key) {
-        Bubble b = mBubbleData.getBubble(key);
+        Bubble b = mBubbleData.getBubbleWithKey(key);
         if (b != null) {
-            b.iconView.updateDotVisibility();
+            b.updateDotVisibility();
         }
     }
 
@@ -407,16 +402,6 @@
     }
 
     /**
-     * Sets the listener to notify when a bubble is blocked.
-     */
-    public void setOnBlockedListener(BubbleExpandedView.OnBubbleBlockedListener listener) {
-        mBlockedListener = listener;
-        for (Bubble b : mBubbleData.getBubbles()) {
-            b.expandedView.setOnBlockedListener(mBlockedListener);
-        }
-    }
-
-    /**
      * Whether the stack of bubbles is expanded or not.
      */
     public boolean isExpanded() {
@@ -445,7 +430,7 @@
      */
     @Deprecated
     void setExpandedBubble(String key) {
-        Bubble bubbleToExpand = mBubbleData.getBubble(key);
+        Bubble bubbleToExpand = mBubbleData.getBubbleWithKey(key);
         if (bubbleToExpand != null) {
             setSelectedBubble(bubbleToExpand);
             bubbleToExpand.entry.setShowInShadeWhenBubble(false);
@@ -466,11 +451,36 @@
         }
     }
 
+    // via BubbleData.Listener
+    void addBubble(Bubble bubble) {
+        bubble.inflate(mInflater, this);
+        mBubbleContainer.addView(bubble.iconView, 0,
+                new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+        ViewClippingUtil.setClippingDeactivated(bubble.iconView, true, mClippingParameters);
+        requestUpdate();
+        logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__POSTED);
+    }
+
+    // via BubbleData.Listener
+    void removeBubble(Bubble bubble) {
+        // Remove it from the views
+        int removedIndex = mBubbleContainer.indexOfChild(bubble.iconView);
+        mBubbleContainer.removeViewAt(removedIndex);
+        logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED);
+    }
+
+    // via BubbleData.Listener
+    void updateBubble(Bubble bubble) {
+        requestUpdate();
+        logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__UPDATED);
+    }
+
     /**
      * 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
      * position of any bubble.
      */
+    // via BubbleData.Listener
     public void setSelectedBubble(Bubble bubbleToSelect) {
         if (mExpandedBubble != null && mExpandedBubble.equals(bubbleToSelect)) {
             return;
@@ -489,7 +499,8 @@
                 logBubbleEvent(previouslySelected, StatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
                 logBubbleEvent(bubbleToSelect, StatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
                 notifyExpansionChanged(previouslySelected.entry, false /* expanded */);
-                notifyExpansionChanged(bubbleToSelect.entry, true /* expanded */);
+                notifyExpansionChanged(bubbleToSelect == null ? null : bubbleToSelect.entry,
+                        true /* expanded */);
             });
         }
     }
@@ -497,13 +508,15 @@
     /**
      * Changes the expanded state of the stack.
      *
-     * @param expanded whether the bubble stack should appear expanded
+     * @param shouldExpand whether the bubble stack should appear expanded
      */
-    public void setExpanded(boolean expanded) {
-        if (expanded == mIsExpanded) {
+    // via BubbleData.Listener
+    public void setExpanded(boolean shouldExpand) {
+        boolean wasExpanded = mIsExpanded;
+        if (shouldExpand == wasExpanded) {
             return;
         }
-        if (mIsExpanded) {
+        if (wasExpanded) {
             // Collapse the stack
             animateExpansion(false /* expand */);
             logBubbleEvent(mExpandedBubble, StatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
@@ -518,131 +531,17 @@
     }
 
     /**
-     * Adds a bubble to the top of the stack.
-     *
-     * @param entry the notification to add to the stack of bubbles.
-     */
-    void addBubble(NotificationEntry entry) {
-        Bubble b = new Bubble(entry, mInflater, this /* stackView */, mBlockedListener);
-        mBubbleData.addBubble(b);
-
-        mBubbleContainer.addView(b.iconView, 0,
-                new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
-        ViewClippingUtil.setClippingDeactivated(b.iconView, true, mClippingParameters);
-
-        requestUpdate();
-        logBubbleEvent(b, StatsLog.BUBBLE_UICHANGED__ACTION__POSTED);
-
-        animateInFlyoutForBubble(b);
-    }
-
-    /**
-     * Remove a bubble from the stack.
-     */
-    void removeBubble(String key, int reason) {
-        Bubble b = mBubbleData.removeBubble(key);
-        if (b == null) {
-            return;
-        }
-        setBubbleDismissed(b, reason);
-
-        // Remove it from the views
-        int removedIndex = mBubbleContainer.indexOfChild(b.iconView);
-        mBubbleContainer.removeViewAt(removedIndex);
-
-        int bubbleCount = mBubbleContainer.getChildCount();
-        if (bubbleCount == 0) {
-            // If no bubbles remain, collapse the entire stack.
-            collapseStack();
-            return;
-        } else if (b.equals(mExpandedBubble)) {
-            // Was the current bubble just removed?
-            // If we have other bubbles and are expanded go to the next one or previous
-            // if the bubble removed was last
-            int nextIndex = bubbleCount > removedIndex ? removedIndex : bubbleCount - 1;
-            BubbleView expandedBubble = (BubbleView) mBubbleContainer.getChildAt(nextIndex);
-            if (mIsExpanded) {
-                setExpandedBubble(expandedBubble.getKey());
-            } else {
-                mExpandedBubble = null;
-            }
-        }
-        // TODO: consider logging reason code
-        logBubbleEvent(b, StatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED);
-    }
-
-    /**
      * Dismiss the stack of bubbles.
+     * @deprecated
      */
+    @Deprecated
     void stackDismissed(int reason) {
-        for (Bubble bubble : mBubbleData.getBubbles()) {
-            setBubbleDismissed(bubble, reason);
-        }
-        mBubbleData.clear();
-        collapseStack();
-        mBubbleContainer.removeAllViews();
-        mExpandedViewContainer.removeAllViews();
-        // TODO: consider logging reason code
+        mBubbleData.dismissAll(reason);
         logBubbleEvent(null /* no bubble associated with bubble stack dismiss */,
                 StatsLog.BUBBLE_UICHANGED__ACTION__STACK_DISMISSED);
     }
 
     /**
-     * Marks the notification entry as dismissed & calls any delete intents for the bubble.
-     *
-     * <p>Note: This does not remove the Bubble from BubbleData.
-     *
-     * @param bubble the Bubble being dismissed
-     * @param reason code for the reason the dismiss was triggered
-     * @see BubbleController.DismissReason
-     */
-    private void setBubbleDismissed(Bubble bubble, @DismissReason int reason) {
-        if (DEBUG) {
-            Log.d(TAG, "dismissBubble: " + bubble + " reason=" + reason);
-        }
-        bubble.entry.setBubbleDismissed(true);
-        bubble.expandedView.cleanUpExpandedState();
-
-        if (reason == BubbleController.DISMISS_USER_GESTURE) {
-            Notification.BubbleMetadata bubbleMetadata = bubble.entry.getBubbleMetadata();
-            PendingIntent deleteIntent = bubbleMetadata != null
-                    ? bubbleMetadata.getDeleteIntent()
-                    : null;
-            if (deleteIntent != null) {
-                try {
-                    deleteIntent.send();
-                } catch (PendingIntent.CanceledException e) {
-                    Log.w(TAG, "Failed to send delete intent for bubble with key: "
-                            + (bubble.entry != null ? bubble.entry.key : " null entry"));
-                }
-            }
-        }
-    }
-
-    /**
-     * Updates a bubble in the stack.
-     *  @param entry the entry to update in the stack.
-     */
-    public void updateBubble(NotificationEntry entry) {
-        Bubble b = mBubbleData.getBubble(entry.key);
-        mBubbleData.updateBubble(entry.key, entry);
-
-        if (!mIsExpanded) {
-            // If alerting it gets promoted to top of the stack.
-            if (mBubbleContainer.indexOfChild(b.iconView) != 0) {
-                mBubbleContainer.moveViewTo(b.iconView, 0);
-            }
-            requestUpdate();
-            animateInFlyoutForBubble(b /* bubble */);
-        }
-        if (mIsExpanded && entry.equals(mExpandedBubble.entry)) {
-            entry.setShowInShadeWhenBubble(false);
-            requestUpdate();
-        }
-        logBubbleEvent(b, StatsLog.BUBBLE_UICHANGED__ACTION__UPDATED);
-    }
-
-    /**
      * @return the view the touch event is on
      */
     @Nullable
@@ -670,7 +569,7 @@
         return this;
     }
 
-    public View getFlyoutView() {
+    View getFlyoutView() {
         return mFlyout;
     }
 
@@ -683,13 +582,8 @@
      */
     @Deprecated
     @MainThread
-    public void collapseStack() {
-        if (mIsExpanded) {
-            // TODO: Save opened bubble & move it to top of stack
-            animateExpansion(false /* shouldExpand */);
-            notifyExpansionChanged(mExpandedBubble.entry, mIsExpanded);
-            logBubbleEvent(mExpandedBubble, StatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
-        }
+    void collapseStack() {
+        mBubbleData.setExpanded(false);
     }
 
     /**
@@ -712,12 +606,8 @@
      */
     @Deprecated
     @MainThread
-    public void expandStack() {
-        if (!mIsExpanded) {
-            String expandedBubbleKey = getBubbleAt(0).getKey();
-            setExpandedBubble(expandedBubbleKey);
-            logBubbleEvent(mExpandedBubble, StatsLog.BUBBLE_UICHANGED__ACTION__STACK_EXPANDED);
-        }
+    void expandStack() {
+        mBubbleData.setExpanded(true);
     }
 
     /**