Include bubble changes in ranking & move flagging to BubbleExtractor

Previously, only changes to the "allowBubbles" on the channel or
package would trigger a ranking change. This bit only indicates that
the notification is allowed to bubble -- it doesn't indicate that
the notification *is* a bubble. To allow active notifications to
become bubbles if the user changes the setting, we need to
flag them in response to ranking changes.

This CL moves the bubble flagging code into BubbleExtractor that
way the flag is always updated during ranking changes.

BubbleController listens to ranking changes and adds / removes bubbles
based on the ranking. The ranking needs to have an isBubble bit on
it because ranking changes won't pipe flag updates through. SysUI
uses this bit to flag the entry on SysUI's side.

Moves the shortcut getting / validating code into a helper class.

Also removes the inline reply requirement.

Test: NotificationManagerTest NotificationManagerServiceTest BubbleExtractorTest ShortcutHelperTest BubbleCheckerTest
Bug: 149736441
Change-Id: Ib5b62923c123187ae5f7073ec7ca50d7e20c04b1
Merged-In: Ib5b62923c123187ae5f7073ec7ca50d7e20c04b1
diff --git a/core/java/android/service/notification/NotificationListenerService.java b/core/java/android/service/notification/NotificationListenerService.java
index 0cd96b8..c52b02b 100644
--- a/core/java/android/service/notification/NotificationListenerService.java
+++ b/core/java/android/service/notification/NotificationListenerService.java
@@ -55,7 +55,6 @@
 import android.os.UserHandle;
 import android.util.ArrayMap;
 import android.util.Log;
-import android.util.Slog;
 import android.widget.RemoteViews;
 
 import com.android.internal.annotations.GuardedBy;
@@ -1570,6 +1569,7 @@
         private boolean mVisuallyInterruptive;
         private boolean mIsConversation;
         private ShortcutInfo mShortcutInfo;
+        private boolean mIsBubble;
 
         private static final int PARCEL_VERSION = 2;
 
@@ -1604,6 +1604,7 @@
             out.writeBoolean(mVisuallyInterruptive);
             out.writeBoolean(mIsConversation);
             out.writeParcelable(mShortcutInfo, flags);
+            out.writeBoolean(mIsBubble);
         }
 
         /** @hide */
@@ -1639,6 +1640,7 @@
             mVisuallyInterruptive = in.readBoolean();
             mIsConversation = in.readBoolean();
             mShortcutInfo = in.readParcelable(cl);
+            mIsBubble = in.readBoolean();
         }
 
 
@@ -1844,6 +1846,14 @@
         }
 
         /**
+         * Returns whether this notification is actively a bubble.
+         * @hide
+         */
+        public boolean isBubble() {
+            return mIsBubble;
+        }
+
+        /**
          * @hide
          */
         public @Nullable ShortcutInfo getShortcutInfo() {
@@ -1862,7 +1872,8 @@
                 int userSentiment, boolean hidden, long lastAudiblyAlertedMs,
                 boolean noisy, ArrayList<Notification.Action> smartActions,
                 ArrayList<CharSequence> smartReplies, boolean canBubble,
-                boolean visuallyInterruptive, boolean isConversation, ShortcutInfo shortcutInfo) {
+                boolean visuallyInterruptive, boolean isConversation, ShortcutInfo shortcutInfo,
+                boolean isBubble) {
             mKey = key;
             mRank = rank;
             mIsAmbient = importance < NotificationManager.IMPORTANCE_LOW;
@@ -1886,6 +1897,7 @@
             mVisuallyInterruptive = visuallyInterruptive;
             mIsConversation = isConversation;
             mShortcutInfo = shortcutInfo;
+            mIsBubble = isBubble;
         }
 
         /**
@@ -1913,7 +1925,8 @@
                     other.mCanBubble,
                     other.mVisuallyInterruptive,
                     other.mIsConversation,
-                    other.mShortcutInfo);
+                    other.mShortcutInfo,
+                    other.mIsBubble);
         }
 
         /**
@@ -1970,7 +1983,8 @@
                     && Objects.equals(mIsConversation, other.mIsConversation)
                     // Shortcutinfo doesn't have equals either; use id
                     &&  Objects.equals((mShortcutInfo == null ? 0 : mShortcutInfo.getId()),
-                    (other.mShortcutInfo == null ? 0 : other.mShortcutInfo.getId()));
+                    (other.mShortcutInfo == null ? 0 : other.mShortcutInfo.getId()))
+                    && Objects.equals(mIsBubble, other.mIsBubble);
         }
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index 22c2c7e..1138b02 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -52,6 +52,7 @@
 import android.graphics.Rect;
 import android.os.RemoteException;
 import android.os.ServiceManager;
+import android.service.notification.NotificationListenerService;
 import android.service.notification.NotificationListenerService.RankingMap;
 import android.service.notification.ZenModeConfig;
 import android.util.ArraySet;
@@ -146,13 +147,15 @@
     private BubbleData mBubbleData;
     @Nullable private BubbleStackView mStackView;
     private BubbleIconFactory mBubbleIconFactory;
-    private int mMaxBubbles;
 
     // Tracks the id of the current (foreground) user.
     private int mCurrentUserId;
     // Saves notification keys of active bubbles when users are switched.
     private final SparseSetArray<String> mSavedBubbleKeysPerUser;
 
+    // Used when ranking updates occur and we check if things should bubble / unbubble
+    private NotificationListenerService.Ranking mTmpRanking;
+
     // Saves notification keys of user created "fake" bubbles so that we can allow notifications
     // like these to bubble by default. Doesn't persist across reboots, not a long-term solution.
     private final HashSet<String> mUserCreatedBubbles;
@@ -338,7 +341,6 @@
 
         configurationController.addCallback(this /* configurationListener */);
 
-        mMaxBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_rendered);
         mBubbleData = data;
         mBubbleData.setListener(mBubbleDataListener);
         mBubbleData.setSuppressionChangedListener(new NotificationSuppressionChangedListener() {
@@ -939,9 +941,29 @@
         }
     }
 
+    /**
+     * Called when NotificationListener has received adjusted notification rank and reapplied
+     * filtering and sorting. This is used to dismiss or create bubbles based on changes in
+     * permissions on the notification channel or the global setting.
+     *
+     * @param rankingMap the updated ranking map from NotificationListenerService
+     */
     private void onRankingUpdated(RankingMap rankingMap) {
-        // Forward to BubbleData to block any bubbles which should no longer be shown
-        mBubbleData.notificationRankingUpdated(rankingMap);
+        if (mTmpRanking == null) {
+            mTmpRanking = new NotificationListenerService.Ranking();
+        }
+        String[] orderedKeys = rankingMap.getOrderedKeys();
+        for (int i = 0; i < orderedKeys.length; i++) {
+            String key = orderedKeys[i];
+            NotificationEntry entry = mNotificationEntryManager.getPendingOrActiveNotif(key);
+            rankingMap.getRanking(key, mTmpRanking);
+            if (mBubbleData.hasBubbleWithKey(key) && !mTmpRanking.canBubble()) {
+                mBubbleData.notificationEntryRemoved(entry, BubbleController.DISMISS_BLOCKED);
+            } else if (entry != null && mTmpRanking.isBubble()) {
+                entry.setFlagBubble(true);
+                onEntryUpdated(entry);
+            }
+        }
     }
 
     @SuppressWarnings("FieldCanBeLocal")
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
index cf5a4d3..1fb5908 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleData.java
@@ -26,7 +26,6 @@
 import android.app.PendingIntent;
 import android.content.Context;
 import android.service.notification.NotificationListenerService;
-import android.service.notification.NotificationListenerService.RankingMap;
 import android.util.Log;
 import android.util.Pair;
 
@@ -289,31 +288,6 @@
     }
 
     /**
-     * Called when NotificationListener has received adjusted notification rank and reapplied
-     * filtering and sorting. This is used to dismiss any bubbles which should no longer be shown
-     * due to changes in permissions on the notification channel or the global setting.
-     *
-     * @param rankingMap the updated ranking map from NotificationListenerService
-     */
-    public void notificationRankingUpdated(RankingMap rankingMap) {
-        if (mTmpRanking == null) {
-            mTmpRanking = new NotificationListenerService.Ranking();
-        }
-
-        String[] orderedKeys = rankingMap.getOrderedKeys();
-        for (int i = 0; i < orderedKeys.length; i++) {
-            String key = orderedKeys[i];
-            if (hasBubbleWithKey(key)) {
-                rankingMap.getRanking(key, mTmpRanking);
-                if (!mTmpRanking.canBubble()) {
-                    doRemove(key, BubbleController.DISMISS_BLOCKED);
-                }
-            }
-        }
-        dispatchPendingChanges();
-    }
-
-    /**
      * Adds a group key indicating that the summary for this group should be suppressed.
      *
      * @param groupKey the group key of the group whose summary should be suppressed.
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java
index 047edd2..72d9d0e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java
@@ -206,7 +206,8 @@
                     false,
                     false,
                     false,
-                    null
+                    null,
+                    false
             );
         }
         return ranking;
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java
index fe8b89f..a58000d 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/RankingBuilder.java
@@ -55,6 +55,7 @@
     private boolean mIsVisuallyInterruptive = false;
     private boolean mIsConversation = false;
     private ShortcutInfo mShortcutInfo = null;
+    private boolean mIsBubble = false;
 
     public RankingBuilder() {
     }
@@ -82,6 +83,7 @@
         mIsVisuallyInterruptive = ranking.visuallyInterruptive();
         mIsConversation = ranking.isConversation();
         mShortcutInfo = ranking.getShortcutInfo();
+        mIsBubble = ranking.isBubble();
     }
 
     public Ranking build() {
@@ -108,7 +110,8 @@
                 mCanBubble,
                 mIsVisuallyInterruptive,
                 mIsConversation,
-                mShortcutInfo);
+                mShortcutInfo,
+                mIsBubble);
         return ranking;
     }
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java
index 312bb7f..972357e 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationEntryManagerTest.java
@@ -143,7 +143,7 @@
                     IMPORTANCE_DEFAULT,
                     null, null,
                     null, null, null, true, sentiment, false, -1, false, null, null, false, false,
-                    false, null);
+                    false, null, false);
             return true;
         }).when(mRankingMap).getRanking(eq(key), any(Ranking.class));
     }
@@ -162,7 +162,7 @@
                     null, null,
                     null, null, null, true,
                     Ranking.USER_SENTIMENT_NEUTRAL, false, -1,
-                    false, smartActions, null, false, false, false, null);
+                    false, smartActions, null, false, false, false, null, false);
             return true;
         }).when(mRankingMap).getRanking(eq(key), any(Ranking.class));
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java
index 5d0349d..de5a38c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/row/NotificationEntryManagerInflationTest.java
@@ -284,7 +284,8 @@
                 false,
                 false,
                 false,
-                null);
+                null,
+                false);
         mRankingMap = new NotificationListenerService.RankingMap(new Ranking[] {ranking});
 
         TestableLooper.get(this).processAllMessages();
diff --git a/services/core/java/com/android/server/notification/BubbleExtractor.java b/services/core/java/com/android/server/notification/BubbleExtractor.java
index c9c8042..c96880c 100644
--- a/services/core/java/com/android/server/notification/BubbleExtractor.java
+++ b/services/core/java/com/android/server/notification/BubbleExtractor.java
@@ -15,9 +15,27 @@
 */
 package com.android.server.notification;
 
+import static android.app.Notification.CATEGORY_CALL;
+import static android.app.Notification.FLAG_BUBBLE;
+import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
+
+import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING;
+import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE;
+
+import android.app.ActivityManager;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Person;
 import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
 import android.util.Slog;
 
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.FrameworkStatsLog;
+
+import java.util.ArrayList;
+
 /**
  * Determines whether a bubble can be shown for this notification
  */
@@ -25,10 +43,15 @@
     private static final String TAG = "BubbleExtractor";
     private static final boolean DBG = false;
 
+    private BubbleChecker mBubbleChecker;
     private RankingConfig mConfig;
+    private ActivityManager mActivityManager;
+    private Context mContext;
 
-    public void initialize(Context ctx, NotificationUsageStats usageStats) {
+    public void initialize(Context context, NotificationUsageStats usageStats) {
         if (DBG) Slog.d(TAG, "Initializing  " + getClass().getSimpleName() + ".");
+        mContext = context;
+        mActivityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
     }
 
     public RankingReconsideration process(NotificationRecord record) {
@@ -41,6 +64,12 @@
             if (DBG) Slog.d(TAG, "missing config");
             return null;
         }
+
+        if (mBubbleChecker == null) {
+            if (DBG) Slog.d(TAG, "missing bubble checker");
+            return null;
+        }
+
         boolean appCanShowBubble =
                 mConfig.areBubblesAllowed(record.getSbn().getPackageName(), record.getSbn().getUid());
         if (!mConfig.bubblesEnabled() || !appCanShowBubble) {
@@ -52,7 +81,12 @@
                 record.setAllowBubble(appCanShowBubble);
             }
         }
-
+        final boolean applyFlag = mBubbleChecker.isNotificationAppropriateToBubble(record);
+        if (applyFlag) {
+            record.getNotification().flags |= FLAG_BUBBLE;
+        } else {
+            record.getNotification().flags &= ~FLAG_BUBBLE;
+        }
         return null;
     }
 
@@ -64,4 +98,185 @@
     @Override
     public void setZenHelper(ZenModeHelper helper) {
     }
+
+    /**
+     * Expected to be called after {@link #setConfig(RankingConfig)} has occurred.
+     */
+    void setShortcutHelper(ShortcutHelper helper) {
+        if (mConfig == null) {
+            if (DBG) Slog.d(TAG, "setting shortcut helper prior to setConfig");
+            return;
+        }
+        mBubbleChecker = new BubbleChecker(mContext, helper, mConfig, mActivityManager);
+    }
+
+    @VisibleForTesting
+    void setBubbleChecker(BubbleChecker checker) {
+        mBubbleChecker = checker;
+    }
+
+    /**
+     * Encapsulates special checks to see if a notification can be flagged as a bubble. This
+     * makes testing a bit easier.
+     */
+    public static class BubbleChecker {
+
+        private ActivityManager mActivityManager;
+        private RankingConfig mRankingConfig;
+        private Context mContext;
+        private ShortcutHelper mShortcutHelper;
+
+        BubbleChecker(Context context, ShortcutHelper helper, RankingConfig config,
+                ActivityManager activityManager) {
+            mContext = context;
+            mActivityManager = activityManager;
+            mShortcutHelper = helper;
+            mRankingConfig = config;
+        }
+
+        /**
+         * @return whether the provided notification record is allowed to be represented as a
+         * bubble, accounting for user choice & policy.
+         */
+        public boolean isNotificationAppropriateToBubble(NotificationRecord r) {
+            final String pkg = r.getSbn().getPackageName();
+            final int userId = r.getSbn().getUser().getIdentifier();
+            Notification notification = r.getNotification();
+            if (!canBubble(r, pkg, userId)) {
+                // no log: canBubble has its own
+                return false;
+            }
+
+            if (mActivityManager.isLowRamDevice()) {
+                logBubbleError(r.getKey(), "low ram device");
+                return false;
+            }
+
+            // At this point the bubble must fulfill communication policy
+
+            // Communication always needs a person
+            ArrayList<Person> peopleList = notification.extras != null
+                    ? notification.extras.getParcelableArrayList(Notification.EXTRA_PEOPLE_LIST)
+                    : null;
+            // Message style requires a person & it's not included in the list
+            boolean isMessageStyle = Notification.MessagingStyle.class.equals(
+                    notification.getNotificationStyle());
+            if (!isMessageStyle && (peopleList == null || peopleList.isEmpty())) {
+                logBubbleError(r.getKey(), "Must have a person and be "
+                        + "Notification.MessageStyle or Notification.CATEGORY_CALL");
+                return false;
+            }
+
+            // Communication is a message or a call
+            boolean isCall = CATEGORY_CALL.equals(notification.category);
+            boolean hasForegroundService = (notification.flags & FLAG_FOREGROUND_SERVICE) != 0;
+            if (hasForegroundService && !isCall) {
+                logBubbleError(r.getKey(),
+                        "foreground services must be Notification.CATEGORY_CALL to bubble");
+                return false;
+            }
+            if (isMessageStyle) {
+                return true;
+            } else if (isCall) {
+                if (hasForegroundService) {
+                    return true;
+                }
+                logBubbleError(r.getKey(), "calls require foreground service");
+                return false;
+            }
+            logBubbleError(r.getKey(), "Must be "
+                    + "Notification.MessageStyle or Notification.CATEGORY_CALL");
+            return false;
+        }
+
+        /**
+         * @return whether the user has enabled the provided notification to bubble, does not
+         * account for policy.
+         */
+        @VisibleForTesting
+        boolean canBubble(NotificationRecord r, String pkg, int userId) {
+            Notification notification = r.getNotification();
+            Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
+            if (metadata == null) {
+                // no log: no need to inform dev if they didn't attach bubble metadata
+                return false;
+            }
+            if (!mRankingConfig.bubblesEnabled()) {
+                logBubbleError(r.getKey(), "bubbles disabled for user: " + userId);
+                return false;
+            }
+            if (!mRankingConfig.areBubblesAllowed(pkg, userId)) {
+                logBubbleError(r.getKey(),
+                        "bubbles for package: " + pkg + " disabled for user: " + userId);
+                return false;
+            }
+            if (!r.getChannel().canBubble()) {
+                logBubbleError(r.getKey(),
+                        "bubbles for channel " + r.getChannel().getId() + " disabled");
+                return false;
+            }
+
+            String shortcutId = metadata.getShortcutId();
+            boolean shortcutValid = shortcutId != null
+                    && mShortcutHelper.hasValidShortcutInfo(shortcutId, pkg, r.getUser());
+            if (metadata.getBubbleIntent() == null && !shortcutValid) {
+                // Should have a shortcut if intent is null
+                logBubbleError(r.getKey(),
+                        "couldn't find valid shortcut for bubble with shortcutId: " + shortcutId);
+                return false;
+            }
+            if (shortcutValid) {
+                return true;
+            }
+            // no log: canLaunch method has the failure log
+            return canLaunchInActivityView(mContext, metadata.getBubbleIntent(), pkg);
+        }
+
+        /**
+         * Whether an intent is properly configured to display in an {@link
+         * android.app.ActivityView}.
+         *
+         * @param context       the context to use.
+         * @param pendingIntent the pending intent of the bubble.
+         * @param packageName   the notification package name for this bubble.
+         */
+        // Keep checks in sync with BubbleController#canLaunchInActivityView.
+        @VisibleForTesting
+        protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
+                String packageName) {
+            if (pendingIntent == null) {
+                Slog.w(TAG, "Unable to create bubble -- no intent");
+                return false;
+            }
+
+            Intent intent = pendingIntent.getIntent();
+
+            ActivityInfo info = intent != null
+                    ? intent.resolveActivityInfo(context.getPackageManager(), 0)
+                    : null;
+            if (info == null) {
+                FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED,
+                        packageName,
+                        BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING);
+                Slog.w(TAG, "Unable to send as bubble -- couldn't find activity info for intent: "
+                        + intent);
+                return false;
+            }
+            if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
+                FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED,
+                        packageName,
+                        BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE);
+                Slog.w(TAG, "Unable to send as bubble -- activity is not resizable for intent: "
+                        + intent);
+                return false;
+            }
+            return true;
+        }
+
+        private void logBubbleError(String key, String failureMessage) {
+            if (DBG) {
+                Slog.w(TAG, "Bubble notification: " + key + " failed: " + failureMessage);
+            }
+        }
+    }
 }
diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java
index 69a5b35..d0b2dfc 100755
--- a/services/core/java/com/android/server/notification/NotificationManagerService.java
+++ b/services/core/java/com/android/server/notification/NotificationManagerService.java
@@ -17,7 +17,6 @@
 package com.android.server.notification;
 
 import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
-import static android.app.Notification.CATEGORY_CALL;
 import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY;
 import static android.app.Notification.FLAG_BUBBLE;
 import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
@@ -51,9 +50,6 @@
 import static android.content.Context.BIND_AUTO_CREATE;
 import static android.content.Context.BIND_FOREGROUND_SERVICE;
 import static android.content.Context.BIND_NOT_PERCEPTIBLE;
-import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED;
-import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC;
-import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED;
 import static android.content.pm.PackageManager.FEATURE_LEANBACK;
 import static android.content.pm.PackageManager.FEATURE_TELEVISION;
 import static android.content.pm.PackageManager.MATCH_ALL;
@@ -93,8 +89,6 @@
 import static android.service.notification.NotificationListenerService.TRIM_LIGHT;
 import static android.view.WindowManager.LayoutParams.TYPE_TOAST;
 
-import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING;
-import static com.android.internal.util.FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE;
 import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_CHANNEL_GROUP_PREFERENCES;
 import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_CHANNEL_PREFERENCES;
 import static com.android.internal.util.FrameworkStatsLog.PACKAGE_NOTIFICATION_PREFERENCES;
@@ -131,8 +125,6 @@
 import android.app.NotificationManager;
 import android.app.NotificationManager.Policy;
 import android.app.PendingIntent;
-import android.app.Person;
-import android.app.RemoteInput;
 import android.app.StatsManager;
 import android.app.StatusBarManager;
 import android.app.UriGrantsManager;
@@ -152,7 +144,6 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
-import android.content.pm.ActivityInfo;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.IPackageManager;
 import android.content.pm.LauncherApps;
@@ -160,7 +151,6 @@
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.pm.PackageManagerInternal;
 import android.content.pm.ParceledListSlice;
-import android.content.pm.ShortcutInfo;
 import android.content.pm.UserInfo;
 import android.content.res.Resources;
 import android.database.ContentObserver;
@@ -251,7 +241,6 @@
 import com.android.internal.util.CollectionUtils;
 import com.android.internal.util.DumpUtils;
 import com.android.internal.util.FastXmlSerializer;
-import com.android.internal.util.FrameworkStatsLog;
 import com.android.internal.util.Preconditions;
 import com.android.internal.util.XmlUtils;
 import com.android.internal.util.function.TriPredicate;
@@ -296,7 +285,6 @@
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map.Entry;
@@ -421,7 +409,7 @@
     private RoleObserver mRoleObserver;
     private UserManager mUm;
     private IPlatformCompat mPlatformCompat;
-    private LauncherApps mLauncherAppsService;
+    private ShortcutHelper mShortcutHelper;
 
     final IBinder mForegroundToken = new Binder();
     private WorkerHandler mHandler;
@@ -497,7 +485,8 @@
             "allow-secure-notifications-on-lockscreen";
     private static final String LOCKSCREEN_ALLOW_SECURE_NOTIFICATIONS_VALUE = "value";
 
-    private RankingHelper mRankingHelper;
+    @VisibleForTesting
+    RankingHelper mRankingHelper;
     @VisibleForTesting
     PreferencesHelper mPreferencesHelper;
 
@@ -1186,13 +1175,30 @@
 
         @Override
         public void onNotificationBubbleChanged(String key, boolean isBubble) {
+            String pkg;
+            synchronized (mNotificationLock) {
+                NotificationRecord r = mNotificationsByKey.get(key);
+                pkg = r != null && r.getSbn() != null ? r.getSbn().getPackageName() : null;
+            }
+            boolean isAppForeground = pkg != null
+                    && mActivityManager.getPackageImportance(pkg) == IMPORTANCE_FOREGROUND;
             synchronized (mNotificationLock) {
                 NotificationRecord r = mNotificationsByKey.get(key);
                 if (r != null) {
-                    final StatusBarNotification n = r.getSbn();
-                    final int callingUid = n.getUid();
-                    final String pkg = n.getPackageName();
-                    applyFlagBubble(r, pkg, callingUid, null /* oldEntry */, isBubble);
+                    if (!isBubble) {
+                        // This happens if the user has dismissed the bubble but the notification
+                        // is still active in the shade, enqueuing would create a bubble since
+                        // the notification is technically allowed. Flip the flag so that
+                        // apps querying noMan will know that their notification is not showing
+                        // as a bubble.
+                        r.getNotification().flags &= ~FLAG_BUBBLE;
+                    } else {
+                        // Enqueue will trigger resort & if the flag is allowed to be true it'll
+                        // be applied there.
+                        r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
+                        mHandler.post(new EnqueueNotificationRunnable(r.getUser().getIdentifier(),
+                                r, isAppForeground));
+                    }
                 }
             }
         }
@@ -1219,6 +1225,7 @@
                         flags &= ~Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION;
                     }
                     data.setFlags(flags);
+                    r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
                     mHandler.post(new EnqueueNotificationRunnable(r.getUser().getIdentifier(), r,
                             true /* isAppForeground */));
                 }
@@ -1595,80 +1602,6 @@
         }
     };
 
-    // Key: packageName Value: <shortcutId, notifId>
-    private HashMap<String, HashMap<String, String>> mActiveShortcutBubbles = new HashMap<>();
-
-    private boolean mLauncherAppsCallbackRegistered;
-
-    // Bubbles can be created based on a shortcut, we need to listen for changes to
-    // that shortcut so that we may update the bubble appropriately.
-    private final LauncherApps.Callback mLauncherAppsCallback = new LauncherApps.Callback() {
-        @Override
-        public void onPackageRemoved(String packageName, UserHandle user) {
-        }
-
-        @Override
-        public void onPackageAdded(String packageName, UserHandle user) {
-        }
-
-        @Override
-        public void onPackageChanged(String packageName, UserHandle user) {
-        }
-
-        @Override
-        public void onPackagesAvailable(String[] packageNames, UserHandle user,
-                boolean replacing) {
-        }
-
-        @Override
-        public void onPackagesUnavailable(String[] packageNames, UserHandle user,
-                boolean replacing) {
-        }
-
-        @Override
-        public void onShortcutsChanged(@NonNull String packageName,
-                @NonNull List<ShortcutInfo> shortcuts, @NonNull UserHandle user) {
-            HashMap<String, String> shortcutBubbles = mActiveShortcutBubbles.get(packageName);
-            boolean isAppForeground = packageName != null
-                    && mActivityManager.getPackageImportance(packageName) == IMPORTANCE_FOREGROUND;
-            ArrayList<String> bubbleKeysToRemove = new ArrayList<>();
-            if (shortcutBubbles != null) {
-                // If we can't find one of our bubbles in the shortcut list, that bubble needs
-                // to be removed.
-                for (String shortcutId : shortcutBubbles.keySet()) {
-                    boolean foundShortcut = false;
-                    for (int i = 0; i < shortcuts.size(); i++) {
-                        if (shortcuts.get(i).getId().equals(shortcutId)) {
-                            foundShortcut = true;
-                            break;
-                        }
-                    }
-                    if (!foundShortcut) {
-                        bubbleKeysToRemove.add(shortcutBubbles.get(shortcutId));
-                    }
-                }
-            }
-
-            // Do the removals
-            for (int i = 0; i < bubbleKeysToRemove.size(); i++) {
-                // update flag bubble
-                String bubbleKey = bubbleKeysToRemove.get(i);
-                synchronized (mNotificationLock) {
-                    NotificationRecord r = mNotificationsByKey.get(bubbleKey);
-                    if (r != null) {
-                        final StatusBarNotification n = r.getSbn();
-                        final int callingUid = n.getUid();
-                        final String pkg = n.getPackageName();
-                        applyFlagBubble(r, pkg, callingUid, null /* oldEntry */, isAppForeground);
-                        mHandler.post(new EnqueueNotificationRunnable(user.getIdentifier(), r,
-                                false /* isAppForeground */));
-                    }
-                }
-            }
-        }
-    };
-
-
     private final class SettingsObserver extends ContentObserver {
         private final Uri NOTIFICATION_BADGING_URI
                 = Settings.Secure.getUriFor(Settings.Secure.NOTIFICATION_BADGING);
@@ -1763,8 +1696,8 @@
     }
 
     @VisibleForTesting
-    void setLauncherApps(LauncherApps launcherApps) {
-        mLauncherAppsService = launcherApps;
+    ShortcutHelper getShortcutHelper() {
+        return mShortcutHelper;
     }
 
     @VisibleForTesting
@@ -2314,8 +2247,13 @@
             mRoleObserver = new RoleObserver(getContext().getSystemService(RoleManager.class),
                     mPackageManager, getContext().getMainExecutor());
             mRoleObserver.init();
-            mLauncherAppsService =
+            LauncherApps launcherApps =
                     (LauncherApps) getContext().getSystemService(Context.LAUNCHER_APPS_SERVICE);
+            mShortcutHelper = new ShortcutHelper(launcherApps, mShortcutListener);
+            BubbleExtractor bubbsExtractor = mRankingHelper.findExtractor(BubbleExtractor.class);
+            if (bubbsExtractor != null) {
+                bubbsExtractor.setShortcutHelper(mShortcutHelper);
+            }
             registerNotificationPreferencesPullers();
         } else if (phase == SystemService.PHASE_THIRD_PARTY_APPS_CAN_START) {
             // This observer will force an update when observe is called, causing us to
@@ -3458,7 +3396,7 @@
             ArrayList<ConversationChannelWrapper> conversations =
                     mPreferencesHelper.getConversations(onlyImportant);
             for (ConversationChannelWrapper conversation : conversations) {
-                conversation.setShortcutInfo(getShortcutInfo(
+                conversation.setShortcutInfo(mShortcutHelper.getShortcutInfo(
                         conversation.getNotificationChannel().getConversationId(),
                         conversation.getPkg(),
                         UserHandle.of(UserHandle.getUserId(conversation.getUid()))));
@@ -3481,7 +3419,7 @@
             ArrayList<ConversationChannelWrapper> conversations =
                     mPreferencesHelper.getConversations(pkg, uid);
             for (ConversationChannelWrapper conversation : conversations) {
-                conversation.setShortcutInfo(getShortcutInfo(
+                conversation.setShortcutInfo(mShortcutHelper.getShortcutInfo(
                         conversation.getNotificationChannel().getConversationId(),
                         pkg,
                         UserHandle.of(UserHandle.getUserId(uid))));
@@ -5652,7 +5590,7 @@
             }
         }
 
-        r.setShortcutInfo(getShortcutInfo(notification.getShortcutId(), pkg, user));
+        r.setShortcutInfo(mShortcutHelper.getShortcutInfo(notification.getShortcutId(), pkg, user));
 
         if (!checkDisqualifyingFeatures(userId, notificationUid, id, tag, r,
                 r.getSbn().getOverrideGroupKey() != null)) {
@@ -5780,16 +5718,12 @@
     }
 
     /**
-     * Updates the flags for this notification to reflect whether it is a bubble or not. Some
-     * bubble specific flags only work if the app is foreground, this will strip those flags
+     * Some bubble specific flags only work if the app is foreground, this will strip those flags
      * if the app wasn't foreground.
      */
-    private void updateNotificationBubbleFlags(NotificationRecord r, String pkg, int userId,
-            NotificationRecord oldRecord, boolean isAppForeground) {
-        Notification notification = r.getNotification();
-        applyFlagBubble(r, pkg, userId, oldRecord, true /* desiredFlag */);
-
+    private void updateNotificationBubbleFlags(NotificationRecord r, boolean isAppForeground) {
         // Remove any bubble specific flags that only work when foregrounded
+        Notification notification = r.getNotification();
         Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
         if (!isAppForeground && metadata != null) {
             int flags = metadata.getFlags();
@@ -5799,252 +5733,30 @@
         }
     }
 
-    /**
-     * Handles actually applying or removing {@link Notification#FLAG_BUBBLE}. Performs necessary
-     * checks for the provided record to see if it can actually be a bubble.
-     * Tracks shortcut based bubbles so that we can find out if they've changed or been removed.
-     */
-    private void applyFlagBubble(NotificationRecord r, String pkg, int userId,
-            NotificationRecord oldRecord, boolean desiredFlag) {
-        boolean applyFlag = desiredFlag
-                && isNotificationAppropriateToBubble(r, pkg, userId, oldRecord);
-        final String shortcutId = r.getNotification().getBubbleMetadata() != null
-                ? r.getNotification().getBubbleMetadata().getShortcutId()
-                : null;
-        if (applyFlag) {
-            if (shortcutId != null) {
-                // Must track shortcut based bubbles in case the shortcut is removed
-                HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
-                        r.getSbn().getPackageName());
-                if (packageBubbles == null) {
-                    packageBubbles = new HashMap<>();
+    private ShortcutHelper.ShortcutListener mShortcutListener =
+            new ShortcutHelper.ShortcutListener() {
+                @Override
+                public void onShortcutRemoved(String key) {
+                    String packageName;
+                    synchronized (mNotificationLock) {
+                        NotificationRecord r = mNotificationsByKey.get(key);
+                        packageName = r != null ? r.getSbn().getPackageName() : null;
+                    }
+                    boolean isAppForeground = packageName != null
+                            && mActivityManager.getPackageImportance(packageName)
+                            == IMPORTANCE_FOREGROUND;
+                    synchronized (mNotificationLock) {
+                        NotificationRecord r = mNotificationsByKey.get(key);
+                        if (r != null) {
+                            // Enqueue will trigger resort & flag is updated that way.
+                            r.getNotification().flags |= FLAG_ONLY_ALERT_ONCE;
+                            mHandler.post(
+                                    new NotificationManagerService.EnqueueNotificationRunnable(
+                                            r.getUser().getIdentifier(), r, isAppForeground));
+                        }
+                    }
                 }
-                packageBubbles.put(shortcutId, r.getKey());
-                mActiveShortcutBubbles.put(r.getSbn().getPackageName(), packageBubbles);
-                if (!mLauncherAppsCallbackRegistered) {
-                    mLauncherAppsService.registerCallback(mLauncherAppsCallback, mHandler);
-                    mLauncherAppsCallbackRegistered = true;
-                }
-            }
-            r.getNotification().flags |= FLAG_BUBBLE;
-        } else {
-            if (shortcutId != null) {
-                // No longer track shortcut
-                HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
-                        r.getSbn().getPackageName());
-                if (packageBubbles != null) {
-                    packageBubbles.remove(shortcutId);
-                }
-                if (packageBubbles != null && packageBubbles.isEmpty()) {
-                    mActiveShortcutBubbles.remove(r.getSbn().getPackageName());
-                }
-                if (mLauncherAppsCallbackRegistered && mActiveShortcutBubbles.isEmpty()) {
-                    mLauncherAppsService.unregisterCallback(mLauncherAppsCallback);
-                    mLauncherAppsCallbackRegistered = false;
-                }
-            }
-            r.getNotification().flags &= ~FLAG_BUBBLE;
-        }
-    }
-
-    /**
-     * @return whether the provided notification record is allowed to be represented as a bubble,
-     * accounting for user choice & policy.
-     */
-    private boolean isNotificationAppropriateToBubble(NotificationRecord r, String pkg, int userId,
-            NotificationRecord oldRecord) {
-        Notification notification = r.getNotification();
-        if (!canBubble(r, pkg, userId)) {
-            // no log: canBubble has its own
-            return false;
-        }
-
-        if (mActivityManager.isLowRamDevice()) {
-            logBubbleError(r.getKey(), "low ram device");
-            return false;
-        }
-
-        if (oldRecord != null && (oldRecord.getNotification().flags & FLAG_BUBBLE) != 0) {
-            // This is an update to an active bubble
-            return true;
-        }
-
-        // At this point the bubble must fulfill communication policy
-
-        // Communication always needs a person
-        ArrayList<Person> peopleList = notification.extras != null
-                ? notification.extras.getParcelableArrayList(Notification.EXTRA_PEOPLE_LIST)
-                : null;
-        // Message style requires a person & it's not included in the list
-        boolean isMessageStyle = Notification.MessagingStyle.class.equals(
-                notification.getNotificationStyle());
-        if (!isMessageStyle && (peopleList == null || peopleList.isEmpty())) {
-            logBubbleError(r.getKey(), "Must have a person and be "
-                    + "Notification.MessageStyle or Notification.CATEGORY_CALL");
-            return false;
-        }
-
-        // Communication is a message or a call
-        boolean isCall = CATEGORY_CALL.equals(notification.category);
-        boolean hasForegroundService = (notification.flags & FLAG_FOREGROUND_SERVICE) != 0;
-        if (hasForegroundService && !isCall) {
-            logBubbleError(r.getKey(),
-                    "foreground services must be Notification.CATEGORY_CALL to bubble");
-            return false;
-        }
-        if (isMessageStyle) {
-            if (hasValidRemoteInput(notification)) {
-                return true;
-            }
-            logBubbleError(r.getKey(), "messages require valid remote input");
-            return false;
-        } else if (isCall) {
-            if (hasForegroundService) {
-                return true;
-            }
-            logBubbleError(r.getKey(), "calls require foreground service");
-            return false;
-        }
-        logBubbleError(r.getKey(), "Must be "
-                + "Notification.MessageStyle or Notification.CATEGORY_CALL");
-        return false;
-    }
-
-    /**
-     * @return whether the user has enabled the provided notification to bubble, does not account
-     * for policy.
-     */
-    private boolean canBubble(NotificationRecord r, String pkg, int userId) {
-        Notification notification = r.getNotification();
-        Notification.BubbleMetadata metadata = notification.getBubbleMetadata();
-        if (metadata == null) {
-            // no log: no need to inform dev if they didn't attach bubble metadata
-            return false;
-        }
-        if (!mPreferencesHelper.bubblesEnabled()) {
-            logBubbleError(r.getKey(), "bubbles disabled for user: " + userId);
-            return false;
-        }
-        if (!mPreferencesHelper.areBubblesAllowed(pkg, userId)) {
-            logBubbleError(r.getKey(),
-                    "bubbles for package: " + pkg + " disabled for user: " + userId);
-            return false;
-        }
-        if (!r.getChannel().canBubble()) {
-            logBubbleError(r.getKey(),
-                    "bubbles for channel " + r.getChannel().getId() + " disabled");
-            return false;
-        }
-
-        String shortcutId = metadata.getShortcutId();
-        boolean shortcutValid = shortcutId != null
-                && hasValidShortcutInfo(shortcutId, pkg, r.getUser());
-        if (metadata.getBubbleIntent() == null && !shortcutValid) {
-            // Should have a shortcut if intent is null
-            logBubbleError(r.getKey(), "couldn't find shortcutId for bubble: " + shortcutId);
-            return false;
-        }
-        if (shortcutValid) {
-            return true;
-        }
-        // no log: canLaunch method has the failure log
-        return canLaunchInActivityView(getContext(), metadata.getBubbleIntent(), pkg);
-    }
-
-    private boolean hasValidRemoteInput(Notification n) {
-        // Also check for inline reply
-        Notification.Action[] actions = n.actions;
-        if (actions != null) {
-            // Get the remote inputs
-            for (int i = 0; i < actions.length; i++) {
-                Notification.Action action = actions[i];
-                RemoteInput[] inputs = action.getRemoteInputs();
-                if (inputs != null && inputs.length > 0) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    private ShortcutInfo getShortcutInfo(String shortcutId, String packageName, UserHandle user) {
-        final long token = Binder.clearCallingIdentity();
-        try {
-            if (shortcutId == null || packageName == null || user == null) {
-                return null;
-            }
-            LauncherApps.ShortcutQuery query = new LauncherApps.ShortcutQuery();
-            if (packageName != null) {
-                query.setPackage(packageName);
-            }
-            if (shortcutId != null) {
-                query.setShortcutIds(Arrays.asList(shortcutId));
-            }
-            query.setQueryFlags(FLAG_MATCH_DYNAMIC | FLAG_MATCH_PINNED | FLAG_MATCH_CACHED);
-            List<ShortcutInfo> shortcuts = mLauncherAppsService.getShortcuts(query, user);
-            ShortcutInfo shortcutInfo = shortcuts != null && shortcuts.size() > 0
-                    ? shortcuts.get(0)
-                    : null;
-            return shortcutInfo;
-        } finally {
-            Binder.restoreCallingIdentity(token);
-        }
-    }
-
-    private boolean hasValidShortcutInfo(String shortcutId, String packageName, UserHandle user) {
-        ShortcutInfo shortcutInfo = getShortcutInfo(shortcutId, packageName, user);
-        return shortcutInfo != null && shortcutInfo.isLongLived();
-    }
-
-    private void logBubbleError(String key, String failureMessage) {
-        if (DBG) {
-            Log.w(TAG, "Bubble notification: " + key + " failed: " + failureMessage);
-        }
-    }
-    /**
-     * Whether an intent is properly configured to display in an {@link android.app.ActivityView}.
-     *
-     * @param context       the context to use.
-     * @param pendingIntent the pending intent of the bubble.
-     * @param packageName   the notification package name for this bubble.
-     */
-    // Keep checks in sync with BubbleController#canLaunchInActivityView.
-    @VisibleForTesting
-    protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
-            String packageName) {
-        if (pendingIntent == null) {
-            Log.w(TAG, "Unable to create bubble -- no intent");
-            return false;
-        }
-
-        // Need escalated privileges to get the intent.
-        final long token = Binder.clearCallingIdentity();
-        Intent intent;
-        try {
-            intent = pendingIntent.getIntent();
-        } finally {
-            Binder.restoreCallingIdentity(token);
-        }
-
-        ActivityInfo info = intent != null
-                ? intent.resolveActivityInfo(context.getPackageManager(), 0)
-                : null;
-        if (info == null) {
-            FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED, packageName,
-                    BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_MISSING);
-            Log.w(TAG, "Unable to send as bubble -- couldn't find activity info for intent: "
-                    + intent);
-            return false;
-        }
-        if (!ActivityInfo.isResizeableMode(info.resizeMode)) {
-            FrameworkStatsLog.write(FrameworkStatsLog.BUBBLE_DEVELOPER_ERROR_REPORTED, packageName,
-                    BUBBLE_DEVELOPER_ERROR_REPORTED__ERROR__ACTIVITY_INFO_NOT_RESIZABLE);
-            Log.w(TAG, "Unable to send as bubble -- activity is not resizable for intent: "
-                    + intent);
-            return false;
-        }
-        return true;
-    }
+            };
 
     private void doChannelWarningToast(CharSequence toastText) {
         Binder.withCleanCallingIdentity(() -> {
@@ -6406,6 +6118,8 @@
                     cancelGroupChildrenLocked(r, mCallingUid, mCallingPid, listenerName,
                             mSendDelete, childrenFlagChecker);
                     updateLightsLocked();
+                    mShortcutHelper.maybeListenForShortcutChangesForBubbles(r, true /* isRemoved */,
+                            mHandler);
                 } else {
                     // No notification was found, assume that it is snoozed and cancel it.
                     if (mReason != REASON_SNOOZED) {
@@ -6473,7 +6187,7 @@
                 final String tag = n.getTag();
 
                 // We need to fix the notification up a little for bubbles
-                updateNotificationBubbleFlags(r, pkg, callingUid, old, isAppForeground);
+                updateNotificationBubbleFlags(r, isAppForeground);
 
                 // Handle grouped notifications and bail out early if we
                 // can to avoid extracting signals.
@@ -6643,6 +6357,10 @@
                                 + n.getPackageName());
                     }
 
+                    mShortcutHelper.maybeListenForShortcutChangesForBubbles(r,
+                            false /* isRemoved */,
+                            mHandler);
+
                     maybeRecordInterruptionLocked(r);
 
                     // Log event to statsd
@@ -7402,6 +7120,7 @@
             int[] visibilities = new int[N];
             boolean[] showBadges = new boolean[N];
             boolean[] allowBubbles = new boolean[N];
+            boolean[] isBubble = new boolean[N];
             ArrayList<NotificationChannel> channelBefore = new ArrayList<>(N);
             ArrayList<String> groupKeyBefore = new ArrayList<>(N);
             ArrayList<ArrayList<String>> overridePeopleBefore = new ArrayList<>(N);
@@ -7417,6 +7136,7 @@
                 visibilities[i] = r.getPackageVisibilityOverride();
                 showBadges[i] = r.canShowBadge();
                 allowBubbles[i] = r.canBubble();
+                isBubble[i] = r.getNotification().isBubbleNotification();
                 channelBefore.add(r.getChannel());
                 groupKeyBefore.add(r.getGroupKey());
                 overridePeopleBefore.add(r.getPeopleOverride());
@@ -7435,6 +7155,7 @@
                         || visibilities[i] != r.getPackageVisibilityOverride()
                         || showBadges[i] != r.canShowBadge()
                         || allowBubbles[i] != r.canBubble()
+                        || isBubble[i] != r.getNotification().isBubbleNotification()
                         || !Objects.equals(channelBefore.get(i), r.getChannel())
                         || !Objects.equals(groupKeyBefore.get(i), r.getGroupKey())
                         || !Objects.equals(overridePeopleBefore.get(i), r.getPeopleOverride())
@@ -8597,7 +8318,8 @@
                     record.canBubble(),
                     record.isInterruptive(),
                     record.isConversation(),
-                    record.getShortcutInfo()
+                    record.getShortcutInfo(),
+                    record.getNotification().isBubbleNotification()
             );
             rankings.add(ranking);
         }
diff --git a/services/core/java/com/android/server/notification/ShortcutHelper.java b/services/core/java/com/android/server/notification/ShortcutHelper.java
new file mode 100644
index 0000000..7bbb3b1
--- /dev/null
+++ b/services/core/java/com/android/server/notification/ShortcutHelper.java
@@ -0,0 +1,196 @@
+/*
+ * 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.server.notification;
+
+import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED;
+import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC;
+import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED;
+
+import android.annotation.NonNull;
+import android.content.pm.LauncherApps;
+import android.content.pm.ShortcutInfo;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.UserHandle;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * Helper for querying shortcuts.
+ */
+class ShortcutHelper {
+
+    /**
+     * Listener to call when a shortcut we're tracking has been removed.
+     */
+    interface ShortcutListener {
+        void onShortcutRemoved(String key);
+    }
+
+    private LauncherApps mLauncherAppsService;
+    private ShortcutListener mShortcutListener;
+
+    // Key: packageName Value: <shortcutId, notifId>
+    private HashMap<String, HashMap<String, String>> mActiveShortcutBubbles = new HashMap<>();
+    private boolean mLauncherAppsCallbackRegistered;
+
+    // Bubbles can be created based on a shortcut, we need to listen for changes to
+    // that shortcut so that we may update the bubble appropriately.
+    private final LauncherApps.Callback mLauncherAppsCallback = new LauncherApps.Callback() {
+        @Override
+        public void onPackageRemoved(String packageName, UserHandle user) {
+        }
+
+        @Override
+        public void onPackageAdded(String packageName, UserHandle user) {
+        }
+
+        @Override
+        public void onPackageChanged(String packageName, UserHandle user) {
+        }
+
+        @Override
+        public void onPackagesAvailable(String[] packageNames, UserHandle user,
+                boolean replacing) {
+        }
+
+        @Override
+        public void onPackagesUnavailable(String[] packageNames, UserHandle user,
+                boolean replacing) {
+        }
+
+        @Override
+        public void onShortcutsChanged(@NonNull String packageName,
+                @NonNull List<ShortcutInfo> shortcuts, @NonNull UserHandle user) {
+            HashMap<String, String> shortcutBubbles = mActiveShortcutBubbles.get(packageName);
+            ArrayList<String> bubbleKeysToRemove = new ArrayList<>();
+            if (shortcutBubbles != null) {
+                // If we can't find one of our bubbles in the shortcut list, that bubble needs
+                // to be removed.
+                for (String shortcutId : shortcutBubbles.keySet()) {
+                    boolean foundShortcut = false;
+                    for (int i = 0; i < shortcuts.size(); i++) {
+                        if (shortcuts.get(i).getId().equals(shortcutId)) {
+                            foundShortcut = true;
+                            break;
+                        }
+                    }
+                    if (!foundShortcut) {
+                        bubbleKeysToRemove.add(shortcutBubbles.get(shortcutId));
+                    }
+                }
+            }
+
+            // Let NoMan know about the updates
+            for (int i = 0; i < bubbleKeysToRemove.size(); i++) {
+                // update flag bubble
+                String bubbleKey = bubbleKeysToRemove.get(i);
+                if (mShortcutListener != null) {
+                    mShortcutListener.onShortcutRemoved(bubbleKey);
+                }
+            }
+        }
+    };
+
+    ShortcutHelper(LauncherApps launcherApps, ShortcutListener listener) {
+        mLauncherAppsService = launcherApps;
+        mShortcutListener = listener;
+    }
+
+    @VisibleForTesting
+    void setLauncherApps(LauncherApps launcherApps) {
+        mLauncherAppsService = launcherApps;
+    }
+
+    ShortcutInfo getShortcutInfo(String shortcutId, String packageName, UserHandle user) {
+        if (mLauncherAppsService == null) {
+            return null;
+        }
+        final long token = Binder.clearCallingIdentity();
+        try {
+            if (shortcutId == null || packageName == null || user == null) {
+                return null;
+            }
+            LauncherApps.ShortcutQuery query = new LauncherApps.ShortcutQuery();
+            query.setPackage(packageName);
+            query.setShortcutIds(Arrays.asList(shortcutId));
+            query.setQueryFlags(FLAG_MATCH_DYNAMIC | FLAG_MATCH_PINNED | FLAG_MATCH_CACHED);
+            List<ShortcutInfo> shortcuts = mLauncherAppsService.getShortcuts(query, user);
+            return shortcuts != null && shortcuts.size() > 0
+                    ? shortcuts.get(0)
+                    : null;
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    boolean hasValidShortcutInfo(String shortcutId, String packageName,
+            UserHandle user) {
+        ShortcutInfo shortcutInfo = getShortcutInfo(shortcutId, packageName, user);
+        return shortcutInfo != null && shortcutInfo.isLongLived();
+    }
+
+    /**
+     * Shortcut based bubbles require some extra work to listen for shortcut changes.
+     *
+     * @param r the notification record to check
+     * @param removedNotification true if this notification is being removed
+     * @param handler handler to register the callback with
+     */
+    void maybeListenForShortcutChangesForBubbles(NotificationRecord r, boolean removedNotification,
+            Handler handler) {
+        final String shortcutId = r.getNotification().getBubbleMetadata() != null
+                ? r.getNotification().getBubbleMetadata().getShortcutId()
+                : null;
+        if (shortcutId == null) {
+            return;
+        }
+        if (r.getNotification().isBubbleNotification() && !removedNotification) {
+            // Must track shortcut based bubbles in case the shortcut is removed
+            HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
+                    r.getSbn().getPackageName());
+            if (packageBubbles == null) {
+                packageBubbles = new HashMap<>();
+            }
+            packageBubbles.put(shortcutId, r.getKey());
+            mActiveShortcutBubbles.put(r.getSbn().getPackageName(), packageBubbles);
+            if (!mLauncherAppsCallbackRegistered) {
+                mLauncherAppsService.registerCallback(mLauncherAppsCallback, handler);
+                mLauncherAppsCallbackRegistered = true;
+            }
+        } else {
+            // No longer track shortcut
+            HashMap<String, String> packageBubbles = mActiveShortcutBubbles.get(
+                    r.getSbn().getPackageName());
+            if (packageBubbles != null) {
+                packageBubbles.remove(shortcutId);
+            }
+            if (packageBubbles != null && packageBubbles.isEmpty()) {
+                mActiveShortcutBubbles.remove(r.getSbn().getPackageName());
+            }
+            if (mLauncherAppsCallbackRegistered && mActiveShortcutBubbles.isEmpty()) {
+                mLauncherAppsService.unregisterCallback(mLauncherAppsCallback);
+                mLauncherAppsCallbackRegistered = false;
+            }
+        }
+    }
+}
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/BubbleCheckerTest.java b/services/tests/uiservicestests/src/com/android/server/notification/BubbleCheckerTest.java
new file mode 100644
index 0000000..9636342
--- /dev/null
+++ b/services/tests/uiservicestests/src/com/android/server/notification/BubbleCheckerTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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.server.notification;
+
+import static android.content.pm.ActivityInfo.RESIZE_MODE_RESIZEABLE;
+import static android.content.pm.ActivityInfo.RESIZE_MODE_UNRESIZEABLE;
+
+import static junit.framework.Assert.assertTrue;
+
+import static org.junit.Assert.assertFalse;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.when;
+
+import android.app.ActivityManager;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.os.UserHandle;
+import android.service.notification.StatusBarNotification;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.UiServiceTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class BubbleCheckerTest extends UiServiceTestCase {
+
+    private static final String SHORTCUT_ID = "shortcut";
+    private static final String PKG = "pkg";
+    private static final String KEY = "key";
+    private static final int USER_ID = 1;
+
+    @Mock
+    ActivityManager mActivityManager;
+    @Mock
+    RankingConfig mRankingConfig;
+    @Mock
+    ShortcutHelper mShortcutHelper;
+
+    @Mock
+    NotificationRecord mNr;
+    @Mock
+    UserHandle mUserHandle;
+    @Mock
+    Notification mNotif;
+    @Mock
+    StatusBarNotification mSbn;
+    @Mock
+    NotificationChannel mChannel;
+    @Mock
+    Notification.BubbleMetadata mBubbleMetadata;
+    @Mock
+    PendingIntent mPendingIntent;
+    @Mock
+    Intent mIntent;
+
+    BubbleExtractor.BubbleChecker mBubbleChecker;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        when(mNr.getKey()).thenReturn(KEY);
+        when(mNr.getSbn()).thenReturn(mSbn);
+        when(mNr.getUser()).thenReturn(mUserHandle);
+        when(mUserHandle.getIdentifier()).thenReturn(USER_ID);
+        when(mNr.getChannel()).thenReturn(mChannel);
+        when(mSbn.getPackageName()).thenReturn(PKG);
+        when(mSbn.getUser()).thenReturn(mUserHandle);
+        when(mNr.getNotification()).thenReturn(mNotif);
+        when(mNotif.getBubbleMetadata()).thenReturn(mBubbleMetadata);
+
+        mBubbleChecker = new BubbleExtractor.BubbleChecker(mContext,
+                mShortcutHelper,
+                mRankingConfig,
+                mActivityManager);
+    }
+
+    void setUpIntentBubble() {
+        when(mPendingIntent.getIntent()).thenReturn(mIntent);
+        when(mBubbleMetadata.getBubbleIntent()).thenReturn(mPendingIntent);
+        when(mBubbleMetadata.getShortcutId()).thenReturn(null);
+    }
+
+    void setUpShortcutBubble(boolean isValid) {
+        when(mBubbleMetadata.getShortcutId()).thenReturn(SHORTCUT_ID);
+        when(mShortcutHelper.hasValidShortcutInfo(SHORTCUT_ID, PKG, mUserHandle))
+                .thenReturn(isValid);
+        when(mBubbleMetadata.getBubbleIntent()).thenReturn(null);
+    }
+
+    void setUpBubblesEnabled(boolean feature, boolean app, boolean channel) {
+        when(mRankingConfig.bubblesEnabled()).thenReturn(feature);
+        when(mRankingConfig.areBubblesAllowed(PKG, USER_ID)).thenReturn(app);
+        when(mChannel.canBubble()).thenReturn(channel);
+    }
+
+    void setUpActivityIntent(boolean isResizable) {
+        when(mPendingIntent.getIntent()).thenReturn(mIntent);
+        ActivityInfo info = new ActivityInfo();
+        info.resizeMode = isResizable
+                ? RESIZE_MODE_RESIZEABLE
+                : RESIZE_MODE_UNRESIZEABLE;
+        when(mIntent.resolveActivityInfo(any(), anyInt())).thenReturn(info);
+    }
+
+    //
+    // canBubble
+    //
+
+    @Test
+    public void testCanBubble_true_intentBubble() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
+        setUpIntentBubble();
+        setUpActivityIntent(true /* isResizable */);
+        when(mActivityManager.isLowRamDevice()).thenReturn(false);
+        assertTrue(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    @Test
+    public void testCanBubble_true_shortcutBubble() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
+        setUpShortcutBubble(true /* isValid */);
+        assertTrue(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    @Test
+    public void testCanBubble_false_noIntentInvalidShortcut() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
+        setUpShortcutBubble(false /* isValid */);
+        assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    @Test
+    public void testCanBubble_false_noIntentNoShortcut() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
+        when(mBubbleMetadata.getBubbleIntent()).thenReturn(null);
+        when(mBubbleMetadata.getShortcutId()).thenReturn(null);
+        assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    @Test
+    public void testCanBubbble_false_noMetadata() {
+        setUpBubblesEnabled(true/* feature */, true /* app */, true /* channel */);
+        when(mNotif.getBubbleMetadata()).thenReturn(null);
+        assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    @Test
+    public void testCanBubble_false_bubblesNotEnabled() {
+        setUpBubblesEnabled(false /* feature */, true /* app */, true /* channel */);
+        assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    @Test
+    public void testCanBubble_false_packageNotAllowed() {
+        setUpBubblesEnabled(true /* feature */, false /* app */, true /* channel */);
+        assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    @Test
+    public void testCanBubble_false_channelNotAllowed() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, false /* channel */);
+        assertFalse(mBubbleChecker.canBubble(mNr, PKG, USER_ID));
+    }
+
+    //
+    // canLaunchInActivityView
+    //
+
+    @Test
+    public void testCanLaunchInActivityView_true() {
+        setUpActivityIntent(true /* resizable */);
+        assertTrue(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
+    }
+
+    @Test
+    public void testCanLaunchInActivityView_false_noIntent() {
+        when(mPendingIntent.getIntent()).thenReturn(null);
+        assertFalse(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
+    }
+
+    @Test
+    public void testCanLaunchInActivityView_false_noInfo() {
+        when(mPendingIntent.getIntent()).thenReturn(mIntent);
+        when(mIntent.resolveActivityInfo(any(), anyInt())).thenReturn(null);
+        assertFalse(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
+    }
+
+    @Test
+    public void testCanLaunchInActivityView_false_notResizable() {
+        setUpActivityIntent(false  /* resizable */);
+        assertFalse(mBubbleChecker.canLaunchInActivityView(mContext, mPendingIntent, PKG));
+    }
+
+    //
+    // isNotificationAppropriateToBubble
+    //
+
+    @Test
+    public void testIsNotifAppropriateToBubble_true() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
+        setUpIntentBubble();
+        when(mActivityManager.isLowRamDevice()).thenReturn(false);
+        setUpActivityIntent(true /* resizable */);
+        doReturn(Notification.MessagingStyle.class).when(mNotif).getNotificationStyle();
+
+        assertTrue(mBubbleChecker.isNotificationAppropriateToBubble(mNr));
+    }
+
+    @Test
+    public void testIsNotifAppropriateToBubble_false_lowRam() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
+        when(mActivityManager.isLowRamDevice()).thenReturn(true);
+        setUpActivityIntent(true /* resizable */);
+        doReturn(Notification.MessagingStyle.class).when(mNotif).getNotificationStyle();
+
+        assertFalse(mBubbleChecker.isNotificationAppropriateToBubble(mNr));
+    }
+
+    @Test
+    public void testIsNotifAppropriateToBubble_false_notMessageStyle() {
+        setUpBubblesEnabled(true /* feature */, true /* app */, true /* channel */);
+        when(mActivityManager.isLowRamDevice()).thenReturn(false);
+        setUpActivityIntent(true /* resizable */);
+        doReturn(Notification.BigPictureStyle.class).when(mNotif).getNotificationStyle();
+
+        assertFalse(mBubbleChecker.isNotificationAppropriateToBubble(mNr));
+    }
+
+}
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java b/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java
index 7459c4b..c7cef05 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/BubbleExtractorTest.java
@@ -21,6 +21,7 @@
 import static junit.framework.Assert.assertFalse;
 import static junit.framework.Assert.assertTrue;
 
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import android.app.ActivityManager;
@@ -46,6 +47,7 @@
 public class BubbleExtractorTest extends UiServiceTestCase {
 
     @Mock RankingConfig mConfig;
+    BubbleExtractor mBubbleExtractor;
 
     private String mPkg = "com.android.server.notification";
     private int mId = 1001;
@@ -57,6 +59,10 @@
     @Before
     public void setUp() {
         MockitoAnnotations.initMocks(this);
+        mBubbleExtractor = new BubbleExtractor();
+        mBubbleExtractor.initialize(mContext, mock(NotificationUsageStats.class));
+        mBubbleExtractor.setConfig(mConfig);
+        mBubbleExtractor.setShortcutHelper(mock(ShortcutHelper.class));
     }
 
     private NotificationRecord getNotificationRecord(boolean allow, int importanceHigh) {
@@ -83,70 +89,55 @@
 
     @Test
     public void testAppYesChannelNo() {
-        BubbleExtractor extractor = new BubbleExtractor();
-        extractor.setConfig(mConfig);
-
         when(mConfig.bubblesEnabled()).thenReturn(true);
         when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(true);
         NotificationRecord r = getNotificationRecord(false, IMPORTANCE_UNSPECIFIED);
 
-        extractor.process(r);
+        mBubbleExtractor.process(r);
 
         assertFalse(r.canBubble());
     }
 
     @Test
     public void testAppNoChannelYes() throws Exception {
-        BubbleExtractor extractor = new BubbleExtractor();
-        extractor.setConfig(mConfig);
-
         when(mConfig.bubblesEnabled()).thenReturn(true);
         when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(false);
         NotificationRecord r = getNotificationRecord(true, IMPORTANCE_HIGH);
 
-        extractor.process(r);
+        mBubbleExtractor.process(r);
 
         assertFalse(r.canBubble());
     }
 
     @Test
     public void testAppYesChannelYes() {
-        BubbleExtractor extractor = new BubbleExtractor();
-        extractor.setConfig(mConfig);
-
         when(mConfig.bubblesEnabled()).thenReturn(true);
         when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(true);
         NotificationRecord r = getNotificationRecord(true, IMPORTANCE_UNSPECIFIED);
 
-        extractor.process(r);
+        mBubbleExtractor.process(r);
 
         assertTrue(r.canBubble());
     }
 
     @Test
     public void testAppNoChannelNo() {
-        BubbleExtractor extractor = new BubbleExtractor();
-        extractor.setConfig(mConfig);
-
         when(mConfig.bubblesEnabled()).thenReturn(true);
         when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(false);
         NotificationRecord r = getNotificationRecord(false, IMPORTANCE_UNSPECIFIED);
 
-        extractor.process(r);
+        mBubbleExtractor.process(r);
 
         assertFalse(r.canBubble());
     }
 
     @Test
     public void testAppYesChannelYesUserNo() {
-        BubbleExtractor extractor = new BubbleExtractor();
-        extractor.setConfig(mConfig);
-
         when(mConfig.bubblesEnabled()).thenReturn(false);
         when(mConfig.areBubblesAllowed(mPkg, mUid)).thenReturn(true);
         NotificationRecord r = getNotificationRecord(true, IMPORTANCE_HIGH);
 
-        extractor.process(r);
+        mBubbleExtractor.process(r);
 
         assertFalse(r.canBubble());
     }
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java
index dc8d010..8e78047 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationListenerServiceTest.java
@@ -35,7 +35,6 @@
 import android.app.Notification;
 import android.app.NotificationChannel;
 import android.app.PendingIntent;
-import android.app.Person;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
@@ -191,7 +190,8 @@
                 tweak.canBubble(),
                 tweak.visuallyInterruptive(),
                 tweak.isConversation(),
-                tweak.getShortcutInfo()
+                tweak.getShortcutInfo(),
+                tweak.isBubble()
         );
         assertNotEquals(nru, nru2);
     }
@@ -270,7 +270,8 @@
                     canBubble(i),
                     visuallyInterruptive(i),
                     isConversation(i),
-                    getShortcutInfo(i)
+                    getShortcutInfo(i),
+                    isBubble(i)
             );
             rankings[i] = ranking;
         }
@@ -394,6 +395,10 @@
         return si;
     }
 
+    private boolean isBubble(int index) {
+        return index % 4 == 0;
+    }
+
     private void assertActionsEqual(
             List<Notification.Action> expecteds, List<Notification.Action> actuals) {
         assertEquals(expecteds.size(), actuals.size());
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
index 64d481a..ed1af32 100755
--- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
@@ -41,8 +41,6 @@
 import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_SCREEN_OFF;
 import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_SCREEN_ON;
 import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_STATUS_BAR;
-import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC;
-import static android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED;
 import static android.content.pm.PackageManager.FEATURE_WATCH;
 import static android.content.pm.PackageManager.PERMISSION_DENIED;
 import static android.content.pm.PackageManager.PERMISSION_GRANTED;
@@ -204,6 +202,8 @@
     private TestableNotificationManagerService mService;
     private INotificationManager mBinderService;
     private NotificationManagerInternal mInternalService;
+    private TestableBubbleChecker mTestableBubbleChecker;
+    private ShortcutHelper mShortcutHelper;
     @Mock
     private IPackageManager mPackageManager;
     @Mock
@@ -286,6 +286,10 @@
             super(context, logger, notificationInstanceIdSequence);
         }
 
+        RankingHelper getRankingHelper() {
+            return mRankingHelper;
+        }
+
         @Override
         protected boolean isCallingUidSystem() {
             countSystemChecks++;
@@ -337,6 +341,14 @@
         interface NotificationAssistantAccessGrantedCallback {
             void onGranted(ComponentName assistant, int userId, boolean granted);
         }
+    }
+
+    private class TestableBubbleChecker extends BubbleExtractor.BubbleChecker {
+
+        TestableBubbleChecker(Context context, ShortcutHelper helper, RankingConfig config,
+                ActivityManager manager) {
+            super(context, helper, config, manager);
+        }
 
         @Override
         protected boolean canLaunchInActivityView(Context context, PendingIntent pendingIntent,
@@ -448,7 +460,16 @@
         mService.onBootPhase(SystemService.PHASE_SYSTEM_SERVICES_READY);
 
         mService.setAudioManager(mAudioManager);
-        mService.setLauncherApps(mLauncherApps);
+
+        mShortcutHelper = mService.getShortcutHelper();
+        mShortcutHelper.setLauncherApps(mLauncherApps);
+
+        // Set the testable bubble extractor
+        RankingHelper rankingHelper = mService.getRankingHelper();
+        BubbleExtractor extractor = rankingHelper.findExtractor(BubbleExtractor.class);
+        mTestableBubbleChecker = new TestableBubbleChecker(mContext, mShortcutHelper,
+                mService.mPreferencesHelper, mActivityManager);
+        extractor.setBubbleChecker(mTestableBubbleChecker);
 
         // Tests call directly into the Binder.
         mBinderService = mService.getBinderService();
@@ -5710,6 +5731,9 @@
 
     @Test
     public void testOnBubbleNotificationSuppressionChanged() throws Exception {
+        // Bubbles are allowed!
+        setUpPrefsForBubbles(PKG, mUid, true /* global */, true /* app */, true /* channel */);
+
         // Bubble notification
         NotificationRecord nr = generateMessageBubbleNotifRecord(mTestNotificationChannel, "tag");
 
@@ -6111,8 +6135,10 @@
         assertTrue(notif.isBubbleNotification());
 
         // Test: Remove the shortcut
+        when(mLauncherApps.getShortcuts(any(), any())).thenReturn(null);
         launcherAppsCallback.getValue().onShortcutsChanged(PKG, Collections.emptyList(),
                 new UserHandle(mUid));
+        waitForIdle();
 
         // Verify:
 
@@ -6125,6 +6151,58 @@
         assertFalse(notif2.isBubbleNotification());
     }
 
+
+    @Test
+    public void testNotificationBubbles_shortcut_stopListeningWhenNotifRemoved()
+            throws RemoteException {
+        // Bubbles are allowed!
+        setUpPrefsForBubbles(PKG, mUid, true /* global */, true /* app */, true /* channel */);
+
+        ArgumentCaptor<LauncherApps.Callback> launcherAppsCallback =
+                ArgumentCaptor.forClass(LauncherApps.Callback.class);
+
+        // Messaging notification with shortcut info
+        Notification.BubbleMetadata metadata =
+                getBubbleMetadataBuilder().createShortcutBubble("someshortcutId").build();
+        Notification.Builder nb = getMessageStyleNotifBuilder(false /* addDefaultMetadata */,
+                null /* groupKey */, false /* isSummary */);
+        nb.setBubbleMetadata(metadata);
+        StatusBarNotification sbn = new StatusBarNotification(PKG, PKG, 1,
+                "tag", mUid, 0, nb.build(), new UserHandle(mUid), null, 0);
+        NotificationRecord nr = new NotificationRecord(mContext, sbn, mTestNotificationChannel);
+
+        // Pretend the shortcut exists
+        List<ShortcutInfo> shortcutInfos = new ArrayList<>();
+        ShortcutInfo info = mock(ShortcutInfo.class);
+        when(info.isLongLived()).thenReturn(true);
+        shortcutInfos.add(info);
+        when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcutInfos);
+
+        // Test: Send the bubble notification
+        mBinderService.enqueueNotificationWithTag(PKG, PKG, nr.getSbn().getTag(),
+                nr.getSbn().getId(), nr.getSbn().getNotification(), nr.getSbn().getUserId());
+        waitForIdle();
+
+        // Verify:
+
+        // Make sure we register the callback for shortcut changes
+        verify(mLauncherApps, times(1)).registerCallback(launcherAppsCallback.capture(), any());
+
+        // yes allowed, yes messaging w/shortcut, yes bubble
+        Notification notif = mService.getNotificationRecord(nr.getSbn().getKey()).getNotification();
+        assertTrue(notif.isBubbleNotification());
+
+        // Test: Remove the notification
+        mBinderService.cancelNotificationWithTag(PKG, PKG, nr.getSbn().getTag(),
+                nr.getSbn().getId(), nr.getSbn().getUserId());
+        waitForIdle();
+
+        // Verify:
+
+        // Make sure callback is unregistered
+        verify(mLauncherApps, times(1)).unregisterCallback(launcherAppsCallback.getValue());
+    }
+
     @Test
     public void testNotificationBubbles_bubbleChildrenStay_whenGroupSummaryDismissed()
             throws Exception {
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ShortcutHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ShortcutHelperTest.java
new file mode 100644
index 0000000..50fb9b4
--- /dev/null
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ShortcutHelperTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.server.notification;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.content.pm.LauncherApps;
+import android.content.pm.ShortcutInfo;
+import android.os.UserHandle;
+import android.service.notification.StatusBarNotification;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.testing.TestableLooper;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.UiServiceTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@TestableLooper.RunWithLooper
+public class ShortcutHelperTest extends UiServiceTestCase {
+
+    private static final String SHORTCUT_ID = "shortcut";
+    private static final String PKG = "pkg";
+    private static final String KEY = "key";
+
+    @Mock
+    LauncherApps mLauncherApps;
+    @Mock
+    ShortcutHelper.ShortcutListener mShortcutListener;
+    @Mock
+    NotificationRecord mNr;
+    @Mock
+    Notification mNotif;
+    @Mock
+    StatusBarNotification mSbn;
+    @Mock
+    Notification.BubbleMetadata mBubbleMetadata;
+
+    ShortcutHelper mShortcutHelper;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mShortcutHelper = new ShortcutHelper(mLauncherApps, mShortcutListener);
+        when(mNr.getKey()).thenReturn(KEY);
+        when(mNr.getSbn()).thenReturn(mSbn);
+        when(mSbn.getPackageName()).thenReturn(PKG);
+        when(mNr.getNotification()).thenReturn(mNotif);
+        when(mNotif.getBubbleMetadata()).thenReturn(mBubbleMetadata);
+        when(mBubbleMetadata.getShortcutId()).thenReturn(SHORTCUT_ID);
+    }
+
+    private LauncherApps.Callback addShortcutBubbleAndVerifyListener() {
+        when(mNotif.isBubbleNotification()).thenReturn(true);
+
+        mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
+                false /* removed */,
+                null /* handler */);
+
+        ArgumentCaptor<LauncherApps.Callback> launcherAppsCallback =
+                ArgumentCaptor.forClass(LauncherApps.Callback.class);
+
+        verify(mLauncherApps, times(1)).registerCallback(
+                launcherAppsCallback.capture(), any());
+        return launcherAppsCallback.getValue();
+    }
+
+    @Test
+    public void testBubbleAdded_listenedAdded() {
+        addShortcutBubbleAndVerifyListener();
+    }
+
+    @Test
+    public void testBubbleRemoved_listenerRemoved() {
+        // First set it up to listen
+        addShortcutBubbleAndVerifyListener();
+
+        // Then remove the notif
+        mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
+                true /* removed */,
+                null /* handler */);
+
+        verify(mLauncherApps, times(1)).unregisterCallback(any());
+    }
+
+    @Test
+    public void testBubbleNoLongerBubble_listenerRemoved() {
+        // First set it up to listen
+        addShortcutBubbleAndVerifyListener();
+
+        // Then make it not a bubble
+        when(mNotif.isBubbleNotification()).thenReturn(false);
+        mShortcutHelper.maybeListenForShortcutChangesForBubbles(mNr,
+                false /* removed */,
+                null /* handler */);
+
+        verify(mLauncherApps, times(1)).unregisterCallback(any());
+    }
+
+    @Test
+    public void testListenerNotifiedOnShortcutRemoved() {
+        LauncherApps.Callback callback = addShortcutBubbleAndVerifyListener();
+
+        List<ShortcutInfo> shortcutInfos = new ArrayList<>();
+        when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcutInfos);
+
+        callback.onShortcutsChanged(PKG, shortcutInfos, mock(UserHandle.class));
+        verify(mShortcutListener).onShortcutRemoved(mNr.getKey());
+    }
+}