Auto bubble some notifications (behind debug flag)

When the flag is flipped notifications that fulfill any
of these will bubble:
- use MessageStyle
- have remote input
- have an app overlay intent
- have priority >= HIGH

Test: atest SystemUITests (no new tests)
Bug: 111236845
Change-Id: Iff86d78a24ab798ddb681a1ad59e1131136814d4
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index 70ee747..53862fb 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -22,8 +22,11 @@
 
 import static com.android.systemui.bubbles.BubbleMovementHelper.EDGE_OVERLAP;
 
+import android.app.Notification;
+import android.app.NotificationManager;
 import android.content.Context;
 import android.graphics.Point;
+import android.service.notification.StatusBarNotification;
 import android.view.ViewGroup;
 import android.view.WindowManager;
 import android.widget.FrameLayout;
@@ -33,6 +36,7 @@
 import com.android.systemui.statusbar.notification.NotificationData;
 import com.android.systemui.statusbar.phone.StatusBarWindowController;
 
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -47,7 +51,13 @@
 
     private static final String TAG = "BubbleController";
 
+    // Enables some subset of notifs to automatically become bubbles
+    public static final boolean DEBUG_ENABLE_AUTO_BUBBLE = false;
+    // When a bubble is dismissed, recreate it as a notification
+    public static final boolean DEBUG_DEMOTE_TO_NOTIF = false;
+
     private Context mContext;
+    private BubbleDismissListener mDismissListener;
 
     private Map<String, BubbleView> mBubbles = new HashMap<>();
     private BubbleStackView mStackView;
@@ -56,6 +66,21 @@
     // Bubbles get added to the status bar view
     private StatusBarWindowController mStatusBarWindowController;
 
+    /**
+     * Listener to find out about bubble / bubble stack dismissal events.
+     */
+    public interface BubbleDismissListener {
+        /**
+         * Called when the entire stack of bubbles is dismissed by the user.
+         */
+        void onStackDismissed();
+
+        /**
+         * Called when a specific bubble is dismissed by the user.
+         */
+        void onBubbleDismissed(String key);
+    }
+
     public BubbleController(Context context) {
         mContext = context;
         WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
@@ -65,6 +90,13 @@
     }
 
     /**
+     * Set a listener to be notified of bubble dismissal events.
+     */
+    public void setDismissListener(BubbleDismissListener listener) {
+        mDismissListener = listener;
+    }
+
+    /**
      * Whether or not there are bubbles present, regardless of them being visible on the
      * screen (e.g. if on AOD).
      */
@@ -99,6 +131,9 @@
         for (String key: mBubbles.keySet()) {
             removeBubble(key);
         }
+        if (mDismissListener != null) {
+            mDismissListener.onStackDismissed();
+        }
     }
 
     /**
@@ -146,6 +181,38 @@
             mStackView.removeBubble(bv);
             bv.getEntry().setBubbleDismissed(true);
         }
+        if (mDismissListener != null) {
+            mDismissListener.onBubbleDismissed(key);
+        }
+    }
+
+    /**
+     * Sets the visibility of the bubbles, doesn't un-bubble them, just changes visibility.
+     */
+    public void updateVisibility(boolean visible) {
+        if (mStackView == null) {
+            return;
+        }
+        ArrayList<BubbleView> viewsToRemove = new ArrayList<>();
+        for (BubbleView bv : mBubbles.values()) {
+            NotificationData.Entry entry = bv.getEntry();
+            if (entry != null) {
+                if (entry.row.isRemoved() || entry.isBubbleDismissed() || entry.row.isDismissed()) {
+                    viewsToRemove.add(bv);
+                }
+            }
+        }
+        for (BubbleView view : viewsToRemove) {
+            mBubbles.remove(view.getKey());
+            mStackView.removeBubble(view);
+            if (mBubbles.size() == 0) {
+                ((ViewGroup) mStatusBarWindowController.getStatusBarView()).removeView(mStackView);
+                mStackView = null;
+            }
+        }
+        if (mStackView != null) {
+            mStackView.setVisibility(visible ? VISIBLE : GONE);
+        }
     }
 
     // TODO: factor in PIP location / maybe last place user had it
@@ -166,4 +233,30 @@
         return new Point(EDGE_OVERLAP, size);
     }
 
+    /**
+     * Whether the notification should bubble or not.
+     */
+    public static boolean shouldAutoBubble(NotificationData.Entry entry, int priority,
+            boolean canAppOverlay) {
+        if (!DEBUG_ENABLE_AUTO_BUBBLE || entry.isBubbleDismissed()) {
+            return false;
+        }
+        StatusBarNotification n = entry.notification;
+        boolean hasRemoteInput = false;
+        if (n.getNotification().actions != null) {
+            for (Notification.Action action : n.getNotification().actions) {
+                if (action.getRemoteInputs() != null) {
+                    hasRemoteInput = true;
+                    break;
+                }
+            }
+        }
+        Class<? extends Notification.Style> style = n.getNotification().getNotificationStyle();
+        boolean shouldBubble = priority >= NotificationManager.IMPORTANCE_HIGH
+                || Notification.MessagingStyle.class.equals(style)
+                || Notification.CATEGORY_MESSAGE.equals(n.getNotification().category)
+                || hasRemoteInput
+                || canAppOverlay;
+        return shouldBubble && !entry.isBubbleDismissed();
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationViewHierarchyManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationViewHierarchyManager.java
index 7581d8c..ea67736 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationViewHierarchyManager.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationViewHierarchyManager.java
@@ -16,6 +16,8 @@
 
 package com.android.systemui.statusbar;
 
+import static com.android.systemui.statusbar.StatusBarState.SHADE;
+
 import android.content.Context;
 import android.content.res.Resources;
 import android.os.Trace;
@@ -25,6 +27,7 @@
 
 import com.android.systemui.Dependency;
 import com.android.systemui.R;
+import com.android.systemui.bubbles.BubbleController;
 import com.android.systemui.statusbar.notification.NotificationData;
 import com.android.systemui.statusbar.notification.NotificationEntryManager;
 import com.android.systemui.statusbar.notification.VisualStabilityManager;
@@ -62,6 +65,7 @@
             Dependency.get(StatusBarStateController.class);
     private final NotificationEntryManager mEntryManager =
             Dependency.get(NotificationEntryManager.class);
+    private final BubbleController mBubbleController = Dependency.get(BubbleController.class);
 
     // Lazy
     private ShadeController mShadeController;
@@ -75,6 +79,41 @@
 
     private NotificationPresenter mPresenter;
     private NotificationListContainer mListContainer;
+    private StatusBarStateListener mStatusBarStateListener;
+
+    /**
+     * Listens for the current state of the status bar and updates the visibility state
+     * of bubbles as needed.
+     */
+    public class StatusBarStateListener implements StatusBarStateController.StateListener {
+        private int mState;
+        private BubbleController mController;
+
+        public StatusBarStateListener(BubbleController controller) {
+            mController = controller;
+        }
+
+        /**
+         * Returns the current status bar state.
+         */
+        public int getCurrentState() {
+            return mState;
+        }
+
+        @Override
+        public void onStateChanged(int newState) {
+            mState = newState;
+            // Order here matters because we need to remove the expandable notification row
+            // from it's current parent (NSSL or bubble) before it can be added to the new parent
+            if (mState == SHADE) {
+                updateNotificationViews();
+                mController.updateVisibility(true);
+            } else {
+                mController.updateVisibility(false);
+                updateNotificationViews();
+            }
+        }
+    }
 
     private ShadeController getShadeController() {
         if (mShadeController == null) {
@@ -87,6 +126,9 @@
         Resources res = context.getResources();
         mAlwaysExpandNonGroupedNotification =
                 res.getBoolean(R.bool.config_alwaysExpandNonGroupedNotifications);
+        mStatusBarStateListener = new StatusBarStateListener(mBubbleController);
+        mEntryManager.setStatusBarStateListener(mStatusBarStateListener);
+        Dependency.get(StatusBarStateController.class).addListener(mStatusBarStateListener);
     }
 
     public void setUpWithPresenter(NotificationPresenter presenter,
@@ -102,6 +144,7 @@
         ArrayList<NotificationData.Entry> activeNotifications = mEntryManager.getNotificationData()
                 .getActiveNotifications();
         ArrayList<ExpandableNotificationRow> toShow = new ArrayList<>(activeNotifications.size());
+        ArrayList<NotificationData.Entry> toBubble = new ArrayList<>();
         final int N = activeNotifications.size();
         for (int i = 0; i < N; i++) {
             NotificationData.Entry ent = activeNotifications.get(i);
@@ -110,6 +153,14 @@
                 // temporarily become children if they were isolated before.
                 continue;
             }
+            ent.row.setStatusBarState(mStatusBarStateListener.getCurrentState());
+            boolean showAsBubble = ent.isBubble() && !ent.isBubbleDismissed()
+                    && mStatusBarStateListener.getCurrentState() == SHADE;
+            if (showAsBubble) {
+                toBubble.add(ent);
+                continue;
+            }
+
             int userId = ent.notification.getUserId();
 
             // Display public version of the notification if we need to redact.
@@ -210,6 +261,12 @@
 
         }
 
+        for (int i = 0; i < toBubble.size(); i++) {
+            // TODO: might make sense to leave them in the shade and just reposition them
+            NotificationData.Entry ent = toBubble.get(i);
+            mBubbleController.addBubble(ent);
+        }
+
         mVisualStabilityManager.onReorderingFinished();
         // clear the map again for the next usage
         mTmpChildOrderMap.clear();
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationEntryManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationEntryManager.java
index 274d4b0..ac0fe6e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationEntryManager.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationEntryManager.java
@@ -15,13 +15,16 @@
  */
 package com.android.systemui.statusbar.notification;
 
+import static com.android.systemui.bubbles.BubbleController.DEBUG_DEMOTE_TO_NOTIF;
 import static com.android.systemui.statusbar.NotificationRemoteInputManager.ENABLE_REMOTE_INPUT;
 import static com.android.systemui.statusbar.NotificationRemoteInputManager.FORCE_REMOTE_INPUT_HISTORY;
+import static com.android.systemui.statusbar.StatusBarState.SHADE;
 import static com.android.systemui.statusbar.notification.row.NotificationInflater.FLAG_CONTENT_VIEW_AMBIENT;
 import static com.android.systemui.statusbar.notification.row.NotificationInflater.FLAG_CONTENT_VIEW_HEADS_UP;
 
 import android.annotation.Nullable;
 import android.app.Notification;
+import android.app.NotificationChannel;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.Context;
@@ -62,6 +65,7 @@
 import com.android.systemui.InitController;
 import com.android.systemui.R;
 import com.android.systemui.UiOffloadThread;
+import com.android.systemui.bubbles.BubbleController;
 import com.android.systemui.statusbar.AlertingNotificationManager;
 import com.android.systemui.statusbar.AmbientPulseManager;
 import com.android.systemui.statusbar.NotificationLifetimeExtender;
@@ -72,6 +76,7 @@
 import com.android.systemui.statusbar.NotificationRemoteInputManager;
 import com.android.systemui.statusbar.NotificationUiAdjustment;
 import com.android.systemui.statusbar.NotificationUpdateHandler;
+import com.android.systemui.statusbar.NotificationViewHierarchyManager;
 import com.android.systemui.statusbar.notification.NotificationData.KeyguardEnvironment;
 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
 import com.android.systemui.statusbar.notification.row.NotificationGutsManager;
@@ -99,7 +104,7 @@
  */
 public class NotificationEntryManager implements Dumpable, NotificationInflater.InflationCallback,
         ExpandableNotificationRow.ExpansionLogger, NotificationUpdateHandler,
-        VisualStabilityManager.Callback {
+        VisualStabilityManager.Callback, BubbleController.BubbleDismissListener {
     private static final String TAG = "NotificationEntryMgr";
     protected static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
     protected static final boolean ENABLE_HEADS_UP = true;
@@ -124,6 +129,7 @@
             Dependency.get(ForegroundServiceController.class);
     private final AmbientPulseManager mAmbientPulseManager =
             Dependency.get(AmbientPulseManager.class);
+    private final BubbleController mBubbleController = Dependency.get(BubbleController.class);
 
     // Lazily retrieved dependencies
     private NotificationRemoteInputManager mRemoteInputManager;
@@ -146,6 +152,7 @@
     protected final ArrayList<NotificationLifetimeExtender> mNotificationLifetimeExtenders
             = new ArrayList<>();
     private ExpandableNotificationRow.OnAppOpsClickListener mOnAppOpsClickListener;
+    private NotificationViewHierarchyManager.StatusBarStateListener mStatusBarStateListener;
 
     private final class NotificationClicker implements View.OnClickListener {
 
@@ -175,6 +182,11 @@
             row.setJustClicked(true);
             DejankUtils.postAfterTraversal(() -> row.setJustClicked(false));
 
+            // If it was a bubble we should close it
+            if (row.getEntry().isBubble()) {
+                mBubbleController.collapseStack();
+            }
+
             mCallback.onNotificationClicked(sbn, row);
         }
 
@@ -229,6 +241,7 @@
         mDreamManager = IDreamManager.Stub.asInterface(
                 ServiceManager.checkService(DreamService.DREAM_SERVICE));
         mMessagingUtil = new NotificationMessagingUtil(context);
+        mBubbleController.setDismissListener(this /* bubbleEventListener */);
         mNotificationData = new NotificationData();
         Dependency.get(InitController.class).addPostInitTask(this::onPostInit);
     }
@@ -451,6 +464,23 @@
         mCallback.onPerformRemoveNotification(n);
     }
 
+    @Override
+    public void onStackDismissed() {
+        updateNotifications();
+    }
+
+    @Override
+    public void onBubbleDismissed(String key) {
+        NotificationData.Entry entry = mNotificationData.get(key);
+        if (entry != null) {
+            entry.setBubbleDismissed(true);
+            if (!DEBUG_DEMOTE_TO_NOTIF) {
+                performRemoveNotification(entry.notification);
+            }
+        }
+        updateNotifications();
+    }
+
     /**
      * Cancel this notification and tell the StatusBarManagerService / NotificationManagerService
      * about the failure.
@@ -727,7 +757,12 @@
         if (DEBUG) {
             Log.d(TAG, "createNotificationViews(notification=" + sbn + " " + ranking);
         }
+
         NotificationData.Entry entry = new NotificationData.Entry(sbn, ranking);
+        if (shouldAutoBubble(entry)) {
+            entry.setIsBubble(true);
+        }
+
         Dependency.get(LeakDetector.class).trackInstance(entry);
         entry.createIcons(mContext, sbn);
         // Construct the expanded view.
@@ -948,6 +983,11 @@
         }
     }
 
+    public void setStatusBarStateListener(
+            NotificationViewHierarchyManager.StatusBarStateListener listener) {
+        mStatusBarStateListener  = listener;
+    }
+
     /**
      * Whether the notification should peek in from the top and alert the user.
      *
@@ -965,6 +1005,14 @@
             return false;
         }
 
+        // TODO: need to changes this, e.g. should still heads up in expanded shade, might want
+        // message bubble from the bubble to go through heads up path
+        boolean inShade = mStatusBarStateListener != null
+                && mStatusBarStateListener.getCurrentState() == SHADE;
+        if (entry.isBubble() && !entry.isBubbleDismissed() && inShade) {
+            return false;
+        }
+
         if (!canAlertCommon(entry)) {
             if (DEBUG) {
                 Log.d(TAG, "No heads up: notification shouldn't alert: " + sbn.getKey());
@@ -1152,6 +1200,17 @@
         }
     }
 
+
+    /**
+     * Whether a bubble is appropriate to auto-bubble or not.
+     */
+    private boolean shouldAutoBubble(NotificationData.Entry entry) {
+        int priority = mNotificationData.getImportance(entry.key);
+        NotificationChannel channel = mNotificationData.getChannel(entry.key);
+        boolean canAppOverlay = channel != null && channel.canOverlayApps();
+        return BubbleController.shouldAutoBubble(entry, priority, canAppOverlay);
+    }
+
     /**
      * Callback for NotificationEntryManager.
      */