Bubble overflow - core functionality

BubbleStackView
- create show-overflow-button
- create expanded view to show overflow activity

BubbleOverflowActivity
- add adapter for recycler view of BadgedImageViews
- select bubble to promote it out of overflow, to first bubble in row

BubbleExpandedView
- create overflow intent
- add null bubble checks for overflow expanded view

BubbleData
- new list: overflow bubbles
- only repack bubbles in row (leave overflow order alone)
- update sortKey() to account for last access in addition to last
update. When users select a bubble to promote it out of overflow, it
counts as one access (instead of update); if sortKey does not check last
accessed time, the order is lost in the next repacking and the bubble goes
back to overflow.
- remove oldest bubbles if overflow bubble count > 16

BubbleController
- add callback that updates overflow activity when data changes
- allow overflow activity to set callback
- add/remove bubbles from bubble row ui

BadgedImageView
- set update() param to bubble only
- save other params in Bubble.java so that overflow can update
BadgedImageView with just a bubble

Bug: 138116789
Fixes: 148232991
Fixes: 148232992
Test: (manual) add 5+ bubbles: show-overflow-button shows in bubble row
Test: (manual) tap show-overflow-button: overflow shows aged out bubbles
Test: (manual) remove bubbles, count <= 5: overflow button hides
Test: (manual) tap bubble in overflow: bubble promoted to left of top row
Test: atest SystemUITests

Change-Id: I020ee4c9e16b236043c5cc244e610725e86bcc37
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java
index dc24996..601bae2 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java
@@ -89,12 +89,12 @@
     /**
      * Updates the view with provided info.
      */
-    public void update(Bubble bubble, Bitmap bubbleImage, int dotColor, Path dotPath) {
+    public void update(Bubble bubble) {
         mBubble = bubble;
-        setImageBitmap(bubbleImage);
+        setImageBitmap(bubble.getBadgedImage());
         setDotState(DOT_STATE_SUPPRESSED_FOR_FLYOUT);
-        mDotColor = dotColor;
-        drawDot(dotPath);
+        mDotColor = bubble.getDotColor();
+        drawDot(bubble.getDotPath());
         animateDot();
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
index 2d9775d..ccce85c 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
@@ -31,6 +31,8 @@
 import android.content.pm.PackageManager;
 import android.content.pm.ShortcutInfo;
 import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Path;
 import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import android.os.Bundle;
@@ -93,6 +95,9 @@
     }
 
     private FlyoutMessage mFlyoutMessage;
+    private Bitmap mBadgedImage;
+    private int mDotColor;
+    private Path mDotPath;
 
     public static String groupId(NotificationEntry entry) {
         UserHandle user = entry.getSbn().getUser();
@@ -124,6 +129,18 @@
         return mEntry.getSbn().getPackageName();
     }
 
+    public Bitmap getBadgedImage() {
+        return mBadgedImage;
+    }
+
+    public int getDotColor() {
+        return mDotColor;
+    }
+
+    public Path getDotPath() {
+        return mDotPath;
+    }
+
     @Nullable
     public String getAppName() {
         return mAppName;
@@ -205,8 +222,12 @@
         mAppName = info.appName;
         mFlyoutMessage = info.flyoutMessage;
 
+        mBadgedImage = info.badgedBubbleImage;
+        mDotColor = info.dotColor;
+        mDotPath = info.dotPath;
+
         mExpandedView.update(this);
-        mIconView.update(this, info.badgedBubbleImage, info.dotColor, info.dotPath);
+        mIconView.update(this);
     }
 
     /**
@@ -262,6 +283,13 @@
     }
 
     /**
+     * Should be invoked whenever a Bubble is promoted from overflow.
+     */
+    void markUpdatedAt(long lastAccessedMillis) {
+        mLastUpdated = lastAccessedMillis;
+    }
+
+    /**
      * Whether this notification should be shown in the shade when it is also displayed as a
      * bubble.
      */
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index e642d4e..8c9946f 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -103,6 +103,7 @@
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.function.Consumer;
 
 import javax.inject.Inject;
@@ -151,6 +152,7 @@
     private BubbleData mBubbleData;
     @Nullable private BubbleStackView mStackView;
     private BubbleIconFactory mBubbleIconFactory;
+    private int mMaxBubbles;
 
     // Tracks the id of the current (foreground) user.
     private int mCurrentUserId;
@@ -171,6 +173,8 @@
     private StatusBarStateListener mStatusBarStateListener;
     private final ScreenshotHelper mScreenshotHelper;
 
+    // Callback that updates BubbleOverflowActivity on data change.
+    @Nullable private Runnable mOverflowCallback = null;
 
     private final NotificationInterruptionStateProvider mNotificationInterruptionStateProvider;
     private IStatusBarService mBarService;
@@ -301,6 +305,7 @@
 
         mBubbleData = data;
         mBubbleData.setListener(mBubbleDataListener);
+        mMaxBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_rendered);
 
         mNotificationEntryManager = entryManager;
         mNotificationEntryManager.addNotificationEntryListener(mEntryListener);
@@ -370,6 +375,18 @@
         mInflateSynchronously = inflateSynchronously;
     }
 
+    void setOverflowCallback(Runnable updateOverflow) {
+        mOverflowCallback = updateOverflow;
+    }
+
+    /**
+     * @return Bubbles for updating overflow.
+     */
+    List<Bubble> getOverflowBubbles() {
+        return mBubbleData.getOverflowBubbles();
+    }
+
+
     /**
      * BubbleStackView is lazily created by this method the first time a Bubble is added. This
      * method initializes the stack view and adds it to the StatusBar just above the scrim.
@@ -537,6 +554,10 @@
         mBubbleData.setSelectedBubble(bubble);
     }
 
+    void promoteBubbleFromOverflow(Bubble bubble) {
+        mBubbleData.promoteBubbleFromOverflow(bubble);
+    }
+
     /**
      * Request the stack expand if needed, then select the specified Bubble as current.
      *
@@ -817,6 +838,11 @@
 
         @Override
         public void applyUpdate(BubbleData.Update update) {
+            // Update bubbles in overflow.
+            if (mOverflowCallback != null) {
+                mOverflowCallback.run();
+            }
+
             if (update.addedBubble != null) {
                 mStackView.addBubble(update.addedBubble);
             }
@@ -890,6 +916,8 @@
                 mStackView.updateBubble(update.updatedBubble);
             }
 
+            // At this point, the correct bubbles are inflated in the stack.
+            // Make sure the order in bubble data is reflected in bubble row.
             if (update.orderChanged) {
                 mStackView.updateBubbleOrder(update.bubbles);
             }
@@ -912,15 +940,18 @@
             updateStack();
 
             if (DEBUG_BUBBLE_CONTROLLER) {
-                Log.d(TAG, "[BubbleData]");
+                Log.d(TAG, "\n[BubbleData] bubbles:");
                 Log.d(TAG, BubbleDebugConfig.formatBubblesString(mBubbleData.getBubbles(),
                         mBubbleData.getSelectedBubble()));
 
                 if (mStackView != null) {
-                    Log.d(TAG, "[BubbleStackView]");
+                    Log.d(TAG, "\n[BubbleStackView]");
                     Log.d(TAG, BubbleDebugConfig.formatBubblesString(mStackView.getBubblesOnScreen(),
                             mStackView.getExpandedBubble()));
                 }
+                Log.d(TAG, "\n[BubbleData] overflow:");
+                Log.d(TAG, BubbleDebugConfig.formatBubblesString(mBubbleData.getOverflowBubbles(),
+                        null));
             }
         }
     };
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
index cc0824e..8b687e7 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
@@ -78,9 +78,11 @@
 
         // A read-only view of the bubbles list, changes there will be reflected here.
         final List<Bubble> bubbles;
+        final List<Bubble> overflowBubbles;
 
-        private Update(List<Bubble> bubbleOrder) {
-            bubbles = Collections.unmodifiableList(bubbleOrder);
+        private Update(List<Bubble> row, List<Bubble> overflow) {
+            bubbles = Collections.unmodifiableList(row);
+            overflowBubbles = Collections.unmodifiableList(overflow);
         }
 
         boolean anythingChanged() {
@@ -113,11 +115,14 @@
     private final Context mContext;
     /** Bubbles that are actively in the stack. */
     private final List<Bubble> mBubbles;
+    /** Bubbles that aged out to overflow. */
+    private final List<Bubble> mOverflowBubbles;
     /** Bubbles that are being loaded but haven't been added to the stack just yet. */
     private final List<Bubble> mPendingBubbles;
     private Bubble mSelectedBubble;
     private boolean mExpanded;
     private final int mMaxBubbles;
+    private final int mMaxOverflowBubbles;
 
     // State tracked during an operation -- keeps track of what listener events to dispatch.
     private Update mStateChange;
@@ -146,9 +151,11 @@
     public BubbleData(Context context) {
         mContext = context;
         mBubbles = new ArrayList<>();
+        mOverflowBubbles = new ArrayList<>();
         mPendingBubbles = new ArrayList<>();
-        mStateChange = new Update(mBubbles);
+        mStateChange = new Update(mBubbles, mOverflowBubbles);
         mMaxBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_rendered);
+        mMaxOverflowBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_overflow);
     }
 
     public boolean hasBubbles() {
@@ -184,6 +191,19 @@
         dispatchPendingChanges();
     }
 
+    public void promoteBubbleFromOverflow(Bubble bubble) {
+        if (DEBUG_BUBBLE_DATA) {
+            Log.d(TAG, "promoteBubbleFromOverflow: " + bubble);
+        }
+        mOverflowBubbles.remove(bubble);
+        doAdd(bubble);
+        setSelectedBubbleInternal(bubble);
+        // Preserve new order for next repack, which sorts by last updated time.
+        bubble.markUpdatedAt(mTimeSource.currentTimeMillis());
+        trim();
+        dispatchPendingChanges();
+    }
+
     /**
      * Constructs a new bubble or returns an existing one. Does not add new bubbles to
      * bubble data, must go through {@link #notificationEntryUpdated(Bubble, boolean, boolean)}
@@ -343,6 +363,7 @@
             mStateChange.orderChanged = true;
         }
         mStateChange.addedBubble = bubble;
+
         if (!isExpanded()) {
             mStateChange.orderChanged |= packGroup(findFirstIndexForGroup(bubble.getGroupId()));
             // Top bubble becomes selected.
@@ -407,6 +428,17 @@
             mStateChange.orderChanged |= repackAll();
         }
 
+        if (reason == BubbleController.DISMISS_AGED) {
+            if (DEBUG_BUBBLE_DATA) {
+                Log.d(TAG, "overflowing bubble: " + bubbleToRemove);
+            }
+            mOverflowBubbles.add(0, bubbleToRemove);
+            if (mOverflowBubbles.size() == mMaxOverflowBubbles + 1) {
+                // Remove oldest bubble.
+                mOverflowBubbles.remove(mOverflowBubbles.size() - 1);
+            }
+        }
+
         // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
         if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
             // Move selection to the new bubble at the same position.
@@ -454,7 +486,7 @@
         if (mListener != null && mStateChange.anythingChanged()) {
             mListener.applyUpdate(mStateChange);
         }
-        mStateChange = new Update(mBubbles);
+        mStateChange = new Update(mBubbles, mOverflowBubbles);
     }
 
     /**
@@ -689,12 +721,19 @@
     }
 
     /**
-     * The set of bubbles.
+     * The set of bubbles in row.
      */
     @VisibleForTesting(visibility = PRIVATE)
     public List<Bubble> getBubbles() {
         return Collections.unmodifiableList(mBubbles);
     }
+    /**
+     * The set of bubbles in overflow.
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public List<Bubble> getOverflowBubbles() {
+        return Collections.unmodifiableList(mOverflowBubbles);
+    }
 
     @VisibleForTesting(visibility = PRIVATE)
     Bubble getBubbleWithKey(String key) {
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
index 48ce4e9..cf8b2be 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
@@ -24,6 +24,7 @@
 import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
 import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
 
+import android.annotation.Nullable;
 import android.app.ActivityOptions;
 import android.app.ActivityTaskManager;
 import android.app.ActivityView;
@@ -83,7 +84,7 @@
     private ActivityViewStatus mActivityViewStatus = ActivityViewStatus.INITIALIZING;
     private int mTaskId = -1;
 
-    private PendingIntent mBubbleIntent;
+    private PendingIntent mPendingIntent;
 
     private boolean mKeyboardVisible;
     private boolean mNeedsNewHeight;
@@ -98,7 +99,9 @@
     private int[] mTempLoc = new int[2];
     private int mExpandedViewTouchSlop;
 
-    private Bubble mBubble;
+    @Nullable private Bubble mBubble;
+
+    private boolean mIsOverflow;
 
     private BubbleController mBubbleController = Dependency.get(BubbleController.class);
     private WindowManager mWindowManager;
@@ -125,7 +128,7 @@
                                     + "bubble=" + getBubbleKey());
                         }
                         try {
-                            if (mBubble.usingShortcutInfo()) {
+                            if (!mIsOverflow && mBubble.usingShortcutInfo()) {
                                 mActivityView.startShortcutActivity(mBubble.getShortcutInfo(),
                                         options, null /* sourceBounds */);
                             } else {
@@ -133,7 +136,7 @@
                                 // Apply flags to make behaviour match documentLaunchMode=always.
                                 fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
                                 fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
-                                mActivityView.startActivity(mBubbleIntent, fillInIntent, options);
+                                mActivityView.startActivity(mPendingIntent, fillInIntent, options);
                             }
                         } catch (RuntimeException e) {
                             // If there's a runtime exception here then there's something
@@ -141,7 +144,7 @@
                             // the bubble again so we'll just remove it.
                             Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
                                     + ", " + e.getMessage() + "; removing bubble");
-                            mBubbleController.removeBubble(mBubble.getKey(),
+                            mBubbleController.removeBubble(getBubbleKey(),
                                     BubbleController.DISMISS_INVALID_INTENT);
                         }
                     });
@@ -241,6 +244,7 @@
 
         mActivityView = new ActivityView(mContext, null /* attrs */, 0 /* defStyle */,
                 true /* singleTaskInstance */);
+
         // Set ActivityView's alpha value as zero, since there is no view content to be shown.
         setContentVisibility(false);
         addView(mActivityView);
@@ -342,6 +346,15 @@
         mStackView = stackView;
     }
 
+    public void setOverflow(boolean overflow) {
+        mIsOverflow = overflow;
+
+        Intent target = new Intent(mContext, BubbleOverflowActivity.class);
+        mPendingIntent = PendingIntent.getActivity(mContext, /* requestCode */ 0,
+                target, PendingIntent.FLAG_UPDATE_CURRENT);
+        mSettingsIcon.setVisibility(GONE);
+    }
+
     /**
      * Sets the bubble used to populate this view.
      */
@@ -350,14 +363,14 @@
             Log.d(TAG, "update: bubble=" + (bubble != null ? bubble.getKey() : "null"));
         }
         boolean isNew = mBubble == null;
-        if (isNew || bubble.getKey().equals(mBubble.getKey())) {
+        if (isNew || bubble != null && bubble.getKey().equals(mBubble.getKey())) {
             mBubble = bubble;
             mSettingsIcon.setContentDescription(getResources().getString(
                     R.string.bubbles_settings_button_description, bubble.getAppName()));
 
             if (isNew) {
-                mBubbleIntent = mBubble.getBubbleIntent();
-                if (mBubbleIntent != null || mBubble.getShortcutInfo() != null) {
+                mPendingIntent = mBubble.getBubbleIntent();
+                if (mPendingIntent != null || mBubble.getShortcutInfo() != null) {
                     setContentVisibility(false);
                     mActivityView.setVisibility(VISIBLE);
                 }
@@ -393,12 +406,16 @@
         return true;
     }
 
+    // TODO(138116789) Fix overflow height.
     void updateHeight() {
         if (DEBUG_BUBBLE_EXPANDED_VIEW) {
             Log.d(TAG, "updateHeight: bubble=" + getBubbleKey());
         }
         if (usingActivityView()) {
-            float desiredHeight = Math.max(mBubble.getDesiredHeight(mContext), mMinHeight);
+            float desiredHeight = mMinHeight;
+            if (!mIsOverflow) {
+                desiredHeight = Math.max(mBubble.getDesiredHeight(mContext), mMinHeight);
+            }
             float height = Math.min(desiredHeight, getMaxExpandedHeight());
             height = Math.max(height, mMinHeight);
             LayoutParams lp = (LayoutParams) mActivityView.getLayoutParams();
@@ -423,8 +440,12 @@
         int bottomInset = getRootWindowInsets() != null
                 ? getRootWindowInsets().getStableInsetBottom()
                 : 0;
-        return mDisplaySize.y - windowLocation[1] - mSettingsIconHeight - mPointerHeight
+        int mh = mDisplaySize.y - windowLocation[1] - mSettingsIconHeight - mPointerHeight
                 - mPointerMargin - bottomInset;
+        Log.i(TAG, "max exp height: " + mh);
+//        return mDisplaySize.y - windowLocation[1] - mSettingsIconHeight - mPointerHeight
+//                - mPointerMargin - bottomInset;
+        return mh;
     }
 
     /**
@@ -543,7 +564,7 @@
     }
 
     private boolean usingActivityView() {
-        return (mBubbleIntent != null || mBubble.getShortcutInfo() != null)
+        return (mPendingIntent != null || mBubble.getShortcutInfo() != null)
                 && mActivityView != null;
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java
index 4252f72..006de84 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExperimentConfig.java
@@ -76,6 +76,9 @@
     private static final String ALLOW_BUBBLE_MENU = "allow_bubble_screenshot_menu";
     private static final boolean ALLOW_BUBBLE_MENU_DEFAULT = false;
 
+    private static final String ALLOW_BUBBLE_OVERFLOW = "allow_bubble_overflow";
+    private static final boolean ALLOW_BUBBLE_OVERFLOW_DEFAULT = false;
+
     /**
      * When true, if a notification has the information necessary to bubble (i.e. valid
      * contentIntent and an icon or image), then a {@link android.app.Notification.BubbleMetadata}
@@ -141,6 +144,16 @@
     }
 
     /**
+     * When true, show a menu when a bubble is long-pressed, which will allow the user to take
+     * actions on that bubble.
+     */
+    static boolean allowBubbleOverflow(Context context) {
+        return Settings.Secure.getInt(context.getContentResolver(),
+                ALLOW_BUBBLE_OVERFLOW,
+                ALLOW_BUBBLE_OVERFLOW_DEFAULT ? 1 : 0) != 0;
+    }
+
+    /**
      * If {@link #allowAnyNotifToBubble(Context)} is true, this method creates and adds
      * {@link android.app.Notification.BubbleMetadata} to the notification entry as long as
      * the notification has necessary info for BubbleMetadata.
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleOverflowActivity.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleOverflowActivity.java
index d99607f..bea55c8 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleOverflowActivity.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleOverflowActivity.java
@@ -1,15 +1,42 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 package com.android.systemui.bubbles;
 
+import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_OVERFLOW;
+import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
 import android.app.Activity;
 import android.content.res.TypedArray;
 import android.graphics.Color;
 import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
 
 import androidx.recyclerview.widget.GridLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 
 import com.android.systemui.R;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
 import javax.inject.Inject;
 
 /**
@@ -17,9 +44,13 @@
  * Must be public to be accessible to androidx...AppComponentFactory
  */
 public class BubbleOverflowActivity extends Activity {
-    private RecyclerView mRecyclerView;
-    private int mMaxBubbles;
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleOverflowActivity" : TAG_BUBBLES;
+
     private BubbleController mBubbleController;
+    private BubbleOverflowAdapter mAdapter;
+    private RecyclerView mRecyclerView;
+    private List<Bubble> mOverflowBubbles = new ArrayList<>();
+    private int mMaxBubbles;
 
     @Inject
     public BubbleOverflowActivity(BubbleController controller) {
@@ -35,17 +66,42 @@
         mMaxBubbles = getResources().getInteger(R.integer.bubbles_max_rendered);
         mRecyclerView = findViewById(R.id.bubble_overflow_recycler);
         mRecyclerView.setLayoutManager(
-                new GridLayoutManager(getApplicationContext(), /* numberOfColumns */ mMaxBubbles));
+                new GridLayoutManager(getApplicationContext(),
+                        getResources().getInteger(R.integer.bubbles_overflow_columns)));
+
+        mAdapter = new BubbleOverflowAdapter(mOverflowBubbles,
+                mBubbleController::promoteBubbleFromOverflow);
+        mRecyclerView.setAdapter(mAdapter);
+
+        updateData(mBubbleController.getOverflowBubbles());
+        mBubbleController.setOverflowCallback(() -> {
+            updateData(mBubbleController.getOverflowBubbles());
+        });
     }
 
     void setBackgroundColor() {
         final TypedArray ta = getApplicationContext().obtainStyledAttributes(
-                new int[] {android.R.attr.colorBackgroundFloating});
+                new int[]{android.R.attr.colorBackgroundFloating});
         int bgColor = ta.getColor(0, Color.WHITE);
         ta.recycle();
         findViewById(android.R.id.content).setBackgroundColor(bgColor);
     }
 
+    void updateData(List<Bubble> bubbles) {
+        mOverflowBubbles.clear();
+        if (bubbles.size() > mMaxBubbles) {
+            mOverflowBubbles.addAll(bubbles.subList(mMaxBubbles, bubbles.size()));
+        } else {
+            mOverflowBubbles.addAll(bubbles);
+        }
+        mAdapter.notifyDataSetChanged();
+
+        if (DEBUG_OVERFLOW) {
+            Log.d(TAG, "Updated overflow bubbles:\n" + BubbleDebugConfig.formatBubblesString(
+                    mOverflowBubbles, /*selected*/ null));
+        }
+    }
+
     @Override
     public void onStart() {
         super.onStart();
@@ -75,3 +131,48 @@
         super.onDestroy();
     }
 }
+
+class BubbleOverflowAdapter extends RecyclerView.Adapter<BubbleOverflowAdapter.ViewHolder> {
+    private Consumer<Bubble> mPromoteBubbleFromOverflow;
+    private List<Bubble> mBubbles;
+
+    public BubbleOverflowAdapter(List<Bubble> list, Consumer<Bubble> promoteBubble) {
+        mBubbles = list;
+        mPromoteBubbleFromOverflow = promoteBubble;
+    }
+
+    @Override
+    public BubbleOverflowAdapter.ViewHolder onCreateViewHolder(ViewGroup parent,
+            int viewType) {
+        BadgedImageView view = (BadgedImageView) LayoutInflater.from(parent.getContext())
+                .inflate(R.layout.bubble_view, parent, false);
+        view.setPadding(15, 15, 15, 15);
+        return new ViewHolder(view);
+    }
+
+    @Override
+    public void onBindViewHolder(ViewHolder vh, int index) {
+        Bubble bubble = mBubbles.get(index);
+
+        vh.mBadgedImageView.update(bubble);
+        vh.mBadgedImageView.setOnClickListener(view -> {
+            mBubbles.remove(bubble);
+            notifyDataSetChanged();
+            mPromoteBubbleFromOverflow.accept(bubble);
+        });
+    }
+
+    @Override
+    public int getItemCount() {
+        return mBubbles.size();
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder {
+        public BadgedImageView mBadgedImageView;
+
+        public ViewHolder(BadgedImageView v) {
+            super(v);
+            mBadgedImageView = v;
+        }
+    }
+}
\ 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 54a42a6..fe4c915 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -33,6 +33,8 @@
 import android.content.Context;
 import android.content.res.Configuration;
 import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Color;
 import android.graphics.ColorMatrix;
 import android.graphics.ColorMatrixColorFilter;
 import android.graphics.Paint;
@@ -40,6 +42,9 @@
 import android.graphics.PointF;
 import android.graphics.Rect;
 import android.graphics.RectF;
+import android.graphics.drawable.AdaptiveIconDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.InsetDrawable;
 import android.os.Bundle;
 import android.os.VibrationEffect;
 import android.os.Vibrator;
@@ -58,6 +63,7 @@
 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
 import android.view.animation.AccelerateDecelerateInterpolator;
 import android.widget.FrameLayout;
+import android.widget.ImageView;
 
 import androidx.annotation.MainThread;
 import androidx.annotation.Nullable;
@@ -195,8 +201,6 @@
     private int mPointerHeight;
     private int mStatusBarHeight;
     private int mImeOffset;
-    private int mBubbleMenuOffset = 252;
-    private BubbleIconFactory mBubbleIconFactory;
     private Bubble mExpandedBubble;
     private boolean mIsExpanded;
 
@@ -320,6 +324,8 @@
     private Runnable mAfterMagnet;
 
     private int mOrientation = Configuration.ORIENTATION_UNDEFINED;
+    private BubbleExpandedView mOverflowExpandedView;
+    private ImageView mOverflowBtn;
 
     public BubbleStackView(Context context, BubbleData data,
             @Nullable SurfaceSynchronizer synchronizer) {
@@ -369,8 +375,6 @@
         mBubbleContainer.setClipChildren(false);
         addView(mBubbleContainer, new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT));
 
-        mBubbleIconFactory = new BubbleIconFactory(context);
-
         mExpandedViewContainer = new FrameLayout(context);
         mExpandedViewContainer.setElevation(elevation);
         mExpandedViewContainer.setPadding(mExpandedViewPadding, mExpandedViewPadding,
@@ -414,6 +418,10 @@
         setFocusable(true);
         mBubbleContainer.bringToFront();
 
+        if (BubbleExperimentConfig.allowBubbleOverflow(mContext)) {
+            setUpOverflow();
+        }
+
         setOnApplyWindowInsetsListener((View view, WindowInsets insets) -> {
             if (!mIsExpanded || mIsExpansionAnimating) {
                 return view.onApplyWindowInsets(insets);
@@ -421,7 +429,13 @@
             mExpandedAnimationController.updateYPosition(
                     // Update the insets after we're done translating otherwise position
                     // calculation for them won't be correct.
-                    () -> mExpandedBubble.getExpandedView().updateInsets(insets));
+                    () -> {
+                        if (mExpandedBubble == null) {
+                            mOverflowExpandedView.updateInsets(insets);
+                        } else {
+                            mExpandedBubble.getExpandedView().updateInsets(insets);
+                        }
+                    });
             return view.onApplyWindowInsets(insets);
         });
 
@@ -433,7 +447,11 @@
                     // Reposition & adjust the height for new orientation
                     if (mIsExpanded) {
                         mExpandedViewContainer.setTranslationY(getExpandedViewY());
-                        mExpandedBubble.getExpandedView().updateView();
+                        if (mExpandedBubble == null) {
+                            mOverflowExpandedView.updateView();
+                        } else {
+                            mExpandedBubble.getExpandedView().updateView();
+                        }
                     }
 
                     // Need to update the padding around the view
@@ -499,6 +517,51 @@
         mBubbleMenuView = findViewById(R.id.bubble_menu_container);
     }
 
+    private void setUpOverflow() {
+        mOverflowExpandedView = (BubbleExpandedView) mInflater.inflate(
+                R.layout.bubble_expanded_view, this /* root */, false /* attachToRoot */);
+        mOverflowExpandedView.setOverflow(true);
+
+        mInflater.inflate(R.layout.bubble_overflow_button, this);
+        mOverflowBtn = findViewById(R.id.bubble_overflow_button);
+        mOverflowBtn.setOnClickListener(v -> {
+            showOverflow();
+        });
+
+        TypedArray ta = mContext.obtainStyledAttributes(
+                new int[]{android.R.attr.colorBackgroundFloating});
+        int bgColor = ta.getColor(0, Color.WHITE /* default */);
+        ta.recycle();
+
+        InsetDrawable fg = new InsetDrawable(mOverflowBtn.getDrawable(), 28);
+        ColorDrawable bg = new ColorDrawable(bgColor);
+        AdaptiveIconDrawable adaptiveIcon = new AdaptiveIconDrawable(bg, fg);
+        mOverflowBtn.setImageDrawable(adaptiveIcon);
+        mOverflowBtn.setVisibility(GONE);
+    }
+
+    void showOverflow() {
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "Show overflow.");
+        }
+        mExpandedViewContainer.setAlpha(0.0f);
+        mSurfaceSynchronizer.syncSurfaceAndRun(() -> {
+            if (mExpandedBubble != null) {
+                mExpandedBubble.setContentVisibility(false);
+                mExpandedBubble = null;
+            }
+            mExpandedViewContainer.removeAllViews();
+            if (mIsExpanded) {
+                mExpandedViewContainer.addView(mOverflowExpandedView);
+                mOverflowExpandedView.populateExpandedView();
+                mExpandedViewContainer.setVisibility(VISIBLE);
+                mExpandedViewContainer.setAlpha(1.0f);
+                mOverflowExpandedView.setContentVisibility(true);
+            }
+            requestUpdate();
+        });
+    }
+
     private void setUpFlyout() {
         if (mFlyout != null) {
             removeView(mFlyout);
@@ -734,9 +797,10 @@
                 new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
         ViewClippingUtil.setClippingDeactivated(bubble.getIconView(), true, mClippingParameters);
         animateInFlyoutForBubble(bubble);
+        updatePointerPosition();
+        updateOverflowBtnVisibility( /*apply */ true);
         requestUpdate();
         logBubbleEvent(bubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__POSTED);
-        updatePointerPosition();
     }
 
     // via BubbleData.Listener
@@ -753,7 +817,32 @@
         } else {
             Log.d(TAG, "was asked to remove Bubble, but didn't find the view! " + bubble);
         }
-        updatePointerPosition();
+        updateOverflowBtnVisibility(/* apply */ true);
+    }
+
+    private void updateOverflowBtnVisibility(boolean apply) {
+        if (!BubbleExperimentConfig.allowBubbleOverflow(mContext)) {
+            return;
+        }
+        if (mIsExpanded) {
+            if (DEBUG_BUBBLE_STACK_VIEW) {
+                Log.d(TAG, "Expanded && overflow > 0. Show overflow button at");
+                Log.d(TAG, "x: " + mExpandedAnimationController.getOverflowBtnLeft());
+                Log.d(TAG, "y: " + mExpandedAnimationController.getExpandedY());
+            }
+            mOverflowBtn.setX(mExpandedAnimationController.getOverflowBtnLeft());
+            mOverflowBtn.setY(mExpandedAnimationController.getExpandedY());
+            mOverflowBtn.setVisibility(VISIBLE);
+            mExpandedAnimationController.setShowOverflowBtn(true);
+            if (apply) {
+                mExpandedAnimationController.expandFromStack(null /* after */);
+            }
+        } else {
+            if (DEBUG_BUBBLE_STACK_VIEW) {
+                Log.d(TAG, "Collapsed. Hide overflow button.");
+            }
+            mOverflowBtn.setVisibility(GONE);
+        }
     }
 
     // via BubbleData.Listener
@@ -953,6 +1042,12 @@
         final Bubble previouslySelected = mExpandedBubble;
         beforeExpandedViewAnimation();
 
+        if (DEBUG_BUBBLE_STACK_VIEW) {
+            Log.d(TAG, "animateCollapse");
+            Log.d(TAG, BubbleDebugConfig.formatBubblesString(this.getBubblesOnScreen(),
+                    this.getExpandedBubble()));
+        }
+        updateOverflowBtnVisibility(/* apply */ false);
         mBubbleContainer.cancelAllAnimations();
         mExpandedAnimationController.collapseBackToStack(
                 mStackAnimationController.getStackPositionAlongNearestHorizontalEdge()
@@ -960,7 +1055,9 @@
                 () -> {
                     mBubbleContainer.setActiveController(mStackAnimationController);
                     afterExpandedViewAnimation();
-                    previouslySelected.setContentVisibility(false);
+                    if (previouslySelected != null) {
+                        previouslySelected.setContentVisibility(false);
+                    }
                 });
 
         mExpandedViewXAnim.animateToFinalPosition(getCollapsedX());
@@ -975,12 +1072,12 @@
         beforeExpandedViewAnimation();
 
         mBubbleContainer.setActiveController(mExpandedAnimationController);
+        updateOverflowBtnVisibility(/* apply */ false);
         mExpandedAnimationController.expandFromStack(() -> {
             updatePointerPosition();
             afterExpandedViewAnimation();
         } /* after */);
 
-
         mExpandedViewContainer.setTranslationX(getCollapsedX());
         mExpandedViewContainer.setTranslationY(getCollapsedY());
         mExpandedViewContainer.setAlpha(0f);
@@ -1063,9 +1160,11 @@
             Log.d(TAG, "onDragStart()");
         }
         if (mIsExpanded || mIsExpansionAnimating) {
+            if (DEBUG_BUBBLE_STACK_VIEW) {
+                Log.d(TAG, "mIsExpanded or mIsExpansionAnimating");
+            }
             return;
         }
-
         hideBubbleMenu();
         mStackAnimationController.cancelStackPositionAnimations();
         mBubbleContainer.setActiveController(mStackAnimationController);
@@ -1545,7 +1644,11 @@
             if (!mExpandedViewYAnim.isRunning()) {
                 // We're not animating so set the value
                 mExpandedViewContainer.setTranslationY(y);
-                mExpandedBubble.getExpandedView().updateView();
+                if (mExpandedBubble == null) {
+                    mOverflowExpandedView.updateView();
+                } else {
+                    mExpandedBubble.getExpandedView().updateView();
+                }
             } else {
                 // We are animating so update the value; there is an end listener on the animator
                 // that will ensure expandedeView.updateView gets called.
@@ -1571,23 +1674,28 @@
     }
 
     private void updatePointerPosition() {
-        if (DEBUG_BUBBLE_STACK_VIEW) {
-            Log.d(TAG, "updatePointerPosition()");
-        }
-
         Bubble expandedBubble = getExpandedBubble();
         if (expandedBubble == null) {
             return;
         }
 
         int index = getBubbleIndex(expandedBubble);
+        if (index >= mMaxBubbles) {
+            // In between state, where extra bubble will be overflowed, and new bubble added
+            index = 0;
+        }
         float bubbleLeftFromScreenLeft = mExpandedAnimationController.getBubbleLeft(index);
         float halfBubble = mBubbleSize / 2f;
         float bubbleCenter = bubbleLeftFromScreenLeft + halfBubble;
         // Padding might be adjusted for insets, so get it directly from the view
         bubbleCenter -= mExpandedViewContainer.getPaddingLeft();
 
-        expandedBubble.getExpandedView().setPointerPosition(bubbleCenter);
+        if (index >= mMaxBubbles) {
+            Bubble first = mBubbleData.getBubbles().get(0);
+            first.getExpandedView().setPointerPosition(bubbleCenter);
+        } else {
+            expandedBubble.getExpandedView().setPointerPosition(bubbleCenter);
+        }
     }
 
     /**
@@ -1680,7 +1788,11 @@
         if (!isExpanded()) {
             return false;
         }
-        return mExpandedBubble.getExpandedView().performBackPressIfNeeded();
+        if (mExpandedBubble == null) {
+            return mOverflowExpandedView.performBackPressIfNeeded();
+        } else {
+            return mExpandedBubble.getExpandedView().performBackPressIfNeeded();
+        }
     }
 
     /** For debugging only */
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
index 6528f37..6d6969d 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
@@ -67,6 +67,8 @@
     private float mBubblePaddingTop;
     /** Size of each bubble. */
     private float mBubbleSizePx;
+    /** Width of the overflow button. */
+    private float mOverflowBtnWidth;
     /** Height of the status bar. */
     private float mStatusBarHeight;
     /** Size of display. */
@@ -81,7 +83,7 @@
 
     private boolean mAnimatingExpand = false;
     private boolean mAnimatingCollapse = false;
-    private Runnable mAfterExpand;
+    private @Nullable Runnable mAfterExpand;
     private Runnable mAfterCollapse;
     private PointF mCollapsePoint;
 
@@ -97,6 +99,7 @@
     private boolean mSpringingBubbleToTouch = false;
 
     private int mExpandedViewPadding;
+    private boolean mShowOverflowBtn;
 
     public ExpandedAnimationController(Point displaySize, int expandedViewPadding,
             int orientation) {
@@ -116,7 +119,7 @@
     /**
      * Animates expanding the bubbles into a row along the top of the screen.
      */
-    public void expandFromStack(Runnable after) {
+    public void expandFromStack(@Nullable Runnable after) {
         mAnimatingCollapse = false;
         mAnimatingExpand = true;
         mAfterExpand = after;
@@ -150,6 +153,14 @@
         }
     }
 
+    public void setShowOverflowBtn(boolean showBtn) {
+        mShowOverflowBtn = showBtn;
+    }
+
+    public boolean getShowOverflowBtn() {
+        return mShowOverflowBtn;
+    }
+
     /**
      * Animates the bubbles along a curved path, either to expand them along the top or collapse
      * them back into a stack.
@@ -380,6 +391,7 @@
         mStackOffsetPx = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
         mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
         mBubbleSizePx = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+        mOverflowBtnWidth = mBubbleSizePx;
         mStatusBarHeight =
                 res.getDimensionPixelSize(com.android.internal.R.dimen.status_bar_height);
         mBubblesMaxRendered = res.getInteger(R.integer.bubbles_max_rendered);
@@ -498,6 +510,14 @@
         return getRowLeft() + bubbleFromRowLeft;
     }
 
+    public float getOverflowBtnLeft() {
+        if (mLayout == null || mLayout.getChildCount() == 0) {
+            return 0;
+        }
+        return getBubbleLeft(mLayout.getChildCount() - 1) + mBubbleSizePx
+                + getSpaceBetweenBubbles();
+    }
+
     /**
      * When expanded, the bubbles are centered in the screen. In portrait, all available space is
      * used. In landscape we have too much space so the value is restricted. This method accounts
@@ -505,7 +525,7 @@
      *
      * @return the desired width to display the expanded bubbles in.
      */
-    private float getWidthForDisplayingBubbles() {
+    public float getWidthForDisplayingBubbles() {
         final float availableWidth = getAvailableScreenWidth(true /* includeStableInsets */);
         if (mScreenOrientation == Configuration.ORIENTATION_LANDSCAPE) {
             // display size y in landscape will be the smaller dimension of the screen
@@ -551,7 +571,11 @@
 
         final float totalBubbleWidth = bubbleCount * mBubbleSizePx;
         final float totalGapWidth = (bubbleCount - 1) * getSpaceBetweenBubbles();
-        final float rowWidth = totalGapWidth + totalBubbleWidth;
+        float rowWidth = totalGapWidth + totalBubbleWidth;
+        if (mShowOverflowBtn) {
+            rowWidth += getSpaceBetweenBubbles();
+            rowWidth += mOverflowBtnWidth;
+        }
 
         // This display size we're using includes the size of the insets, we want the true
         // center of the display minus the notch here, which means we should include the
@@ -559,7 +583,6 @@
         final float trueCenter = getAvailableScreenWidth(false /* subtractStableInsets */) / 2f;
         final float halfRow = rowWidth / 2f;
         final float rowLeft = trueCenter - halfRow;
-
         return rowLeft;
     }
 
@@ -567,12 +590,12 @@
      * @return Space between bubbles in row above expanded view.
      */
     private float getSpaceBetweenBubbles() {
-        final float rowMargins = mExpandedViewPadding * 2;
-        final float maxRowWidth = getWidthForDisplayingBubbles() - rowMargins;
-
         final float totalBubbleWidth = mBubblesMaxRendered * mBubbleSizePx;
-        final float totalGapWidth = maxRowWidth - totalBubbleWidth;
-
+        final float rowMargins = mExpandedViewPadding * 2;
+        float totalGapWidth = getWidthForDisplayingBubbles() - rowMargins - totalBubbleWidth;
+        if (mShowOverflowBtn) {
+            totalGapWidth -= mBubbleSizePx;
+        }
         final int gapCount = mBubblesMaxRendered - 1;
         final float gapWidth = totalGapWidth / gapCount;
         return gapWidth;