Send deleteIntent when a bubble is dismissed

Test: atest BubbleControllerTest
Bug: 124381186
Change-Id: Ie332dc394c25a143e75b3f9caa46de6386b392db
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index 51b2098..eb85589 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -23,6 +23,8 @@
 import static com.android.systemui.statusbar.StatusBarState.SHADE;
 import static com.android.systemui.statusbar.notification.NotificationAlertingManager.alertAgain;
 
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
 import android.annotation.Nullable;
 import android.app.ActivityManager.RunningTaskInfo;
 import android.app.ActivityTaskManager;
@@ -42,6 +44,7 @@
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
+import androidx.annotation.IntDef;
 import androidx.annotation.MainThread;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -59,6 +62,8 @@
 import com.android.systemui.statusbar.notification.row.NotificationContentInflater.InflationFlag;
 import com.android.systemui.statusbar.phone.StatusBarWindowController;
 
+import java.lang.annotation.Retention;
+
 import javax.inject.Inject;
 import javax.inject.Singleton;
 
@@ -70,10 +75,22 @@
  */
 @Singleton
 public class BubbleController implements BubbleExpandedView.OnBubbleBlockedListener {
-    private static final int MAX_BUBBLES = 5; // TODO: actually enforce this
 
     private static final String TAG = "BubbleController";
 
+    private static final int MAX_BUBBLES = 5; // TODO: actually enforce this
+
+    @Retention(SOURCE)
+    @IntDef({DISMISS_USER_GESTURE, DISMISS_AGED, DISMISS_TASK_FINISHED, DISMISS_BLOCKED,
+            DISMISS_NOTIF_CANCEL, DISMISS_ACCESSIBILITY_ACTION})
+    @interface DismissReason {}
+    static final int DISMISS_USER_GESTURE = 1;
+    static final int DISMISS_AGED = 2;
+    static final int DISMISS_TASK_FINISHED = 3;
+    static final int DISMISS_BLOCKED = 4;
+    static final int DISMISS_NOTIF_CANCEL = 5;
+    static final int DISMISS_ACCESSIBILITY_ACTION = 6;
+
     // Enables some subset of notifs to automatically become bubbles
     private static final boolean DEBUG_ENABLE_AUTO_BUBBLE = false;
 
@@ -248,11 +265,11 @@
     /**
      * Tell the stack of bubbles to be dismissed, this will remove all of the bubbles in the stack.
      */
-    void dismissStack() {
+    void dismissStack(@DismissReason int reason) {
         if (mStackView == null) {
             return;
         }
-        mStackView.stackDismissed();
+        mStackView.stackDismissed(reason);
 
         updateVisibility();
         mNotificationEntryManager.updateNotifications();
@@ -304,9 +321,9 @@
      * Must be called from the main thread.
      */
     @MainThread
-    void removeBubble(String key) {
+    void removeBubble(String key, int reason) {
         if (mStackView != null) {
-            mStackView.removeBubble(key);
+            mStackView.removeBubble(key, reason);
         }
         mNotificationEntryManager.updateNotifications();
         updateVisibility();
@@ -320,7 +337,7 @@
             boolean samePackage = entry.notification.getPackageName().equals(
                     e.notification.getPackageName());
             if (samePackage) {
-                removeBubble(entry.key);
+                removeBubble(entry.key, DISMISS_BLOCKED);
             }
         }
     }
@@ -377,7 +394,7 @@
             }
             if (!removedByUser) {
                 // This was a cancel so we should remove the bubble
-                removeBubble(entry.key);
+                removeBubble(entry.key, DISMISS_NOTIF_CANCEL);
             }
         }
     };
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
index 25ee87a..856b9d6 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
@@ -135,7 +135,8 @@
         public void onTaskRemovalStarted(int taskId) {
             if (mEntry != null) {
                 // Must post because this is called from a binder thread.
-                post(() -> mBubbleController.removeBubble(mEntry.key));
+                post(() -> mBubbleController.removeBubble(mEntry.key,
+                        BubbleController.DISMISS_TASK_FINISHED));
             }
         }
     };
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
index 97aa786..13fc7fe 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -19,6 +19,8 @@
 import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
 import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
 
+import android.app.Notification;
+import android.app.PendingIntent;
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.Outline;
@@ -49,6 +51,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.widget.ViewClippingUtil;
 import com.android.systemui.R;
+import com.android.systemui.bubbles.BubbleController.DismissReason;
 import com.android.systemui.bubbles.animation.ExpandedAnimationController;
 import com.android.systemui.bubbles.animation.PhysicsAnimationLayout;
 import com.android.systemui.bubbles.animation.StackAnimationController;
@@ -62,6 +65,7 @@
  */
 public class BubbleStackView extends FrameLayout {
     private static final String TAG = "BubbleStackView";
+    private static final boolean DEBUG = false;
 
     /**
      * Friction applied to fling animations. Since the stack must land on one of the sides of the
@@ -252,7 +256,7 @@
         }
         switch (action) {
             case AccessibilityNodeInfo.ACTION_DISMISS:
-                stackDismissed();
+                stackDismissed(BubbleController.DISMISS_ACCESSIBILITY_ACTION);
                 return true;
             case AccessibilityNodeInfo.ACTION_COLLAPSE:
                 collapseStack();
@@ -376,18 +380,12 @@
     /**
      * Remove a bubble from the stack.
      */
-    public void removeBubble(String key) {
+    public void removeBubble(String key, int reason) {
         Bubble b = mBubbleData.removeBubble(key);
         if (b == null) {
             return;
         }
-        b.entry.setBubbleDismissed(true);
-
-        // Remove it from the views
-        int removedIndex = mBubbleContainer.indexOfChild(b.iconView);
-        b.expandedView.cleanUpExpandedState();
-        mBubbleContainer.removeView(b.iconView);
-
+        int removedIndex = dismissBubble(b, reason);
         int bubbleCount = mBubbleContainer.getChildCount();
         if (bubbleCount == 0) {
             // If no bubbles remain, collapse the entire stack.
@@ -405,26 +403,63 @@
                 mExpandedBubble = null;
             }
         }
+        // TODO: consider logging reason code
         logBubbleEvent(b, StatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED);
     }
 
     /**
      * Dismiss the stack of bubbles.
      */
-    public void stackDismissed() {
+    public void stackDismissed(int reason) {
         for (Bubble bubble : mBubbleData.getBubbles()) {
-            bubble.entry.setBubbleDismissed(true);
-            bubble.expandedView.cleanUpExpandedState();
+            dismissBubble(bubble, reason);
         }
         mBubbleData.clear();
         collapseStack();
         mBubbleContainer.removeAllViews();
         mExpandedViewContainer.removeAllViews();
+        // TODO: consider logging reason code
         logBubbleEvent(null /* no bubble associated with bubble stack dismiss */,
                 StatsLog.BUBBLE_UICHANGED__ACTION__STACK_DISMISSED);
     }
 
     /**
+     * Marks the notification entry as dismissed, cleans up Bubble icon and expanded view UI
+     * elements and calls deleteIntent if necessary.
+     *
+     * <p>Note: This does not remove the Bubble from BubbleData.
+     *
+     * @param bubble the Bubble being dismissed
+     * @param reason code for the reason the dismiss was triggered
+     * @see BubbleController.DismissReason
+     */
+    private int dismissBubble(Bubble bubble, @DismissReason int reason) {
+        if (DEBUG) {
+            Log.d(TAG, "dismissBubble: " + bubble + " reason=" + reason);
+        }
+        bubble.entry.setBubbleDismissed(true);
+        bubble.expandedView.cleanUpExpandedState();
+
+        // Remove it from the views
+        int removedIndex = mBubbleContainer.indexOfChild(bubble.iconView);
+        mBubbleContainer.removeViewAt(removedIndex);
+
+        if (reason == BubbleController.DISMISS_USER_GESTURE) {
+            Notification.BubbleMetadata bubbleMetadata = bubble.entry.getBubbleMetadata();
+            PendingIntent deleteIntent = bubbleMetadata.getDeleteIntent();
+            if (deleteIntent != null) {
+                try {
+                    deleteIntent.send();
+                } catch (PendingIntent.CanceledException e) {
+                    Log.w(TAG, "Failed to send delete intent for bubble with key: "
+                            + (bubble.entry != null ? bubble.entry.key : " null entry"));
+                }
+            }
+        }
+        return removedIndex;
+    }
+
+    /**
      * Updates a bubble in the stack.
      *
      * @param entry the entry to update in the stack.
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
index c8eebac..a7170d0 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
@@ -140,7 +140,7 @@
             case MotionEvent.ACTION_UP:
                 trackMovement(event);
                 if (mInDismissTarget && isStack) {
-                    mController.dismissStack();
+                    mController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
                 } else if (mMovedEnough) {
                     mVelocityTracker.computeCurrentVelocity(/* maxVelocity */ 1000);
                     final float velX = mVelocityTracker.getXVelocity();
@@ -152,7 +152,8 @@
                         mStack.onBubbleDragFinish(
                                 mTouchedView, viewX, viewY, velX, velY, /* dismissed */ dismissed);
                         if (dismissed) {
-                            mController.removeBubble(((BubbleView) mTouchedView).getKey());
+                            mController.removeBubble(((BubbleView) mTouchedView).getKey(),
+                                    BubbleController.DISMISS_USER_GESTURE);
                         }
                     }
                 } else if (mTouchedView == mStack.getExpandedBubbleView()) {
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
index 0fd7834..42c221a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
@@ -20,10 +20,13 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import android.app.IActivityManager;
+import android.app.PendingIntent;
 import android.content.Context;
 import android.testing.AndroidTestingRunner;
 import android.testing.TestableLooper;
@@ -83,6 +86,9 @@
     @Mock
     private BubbleController.BubbleExpandListener mBubbleExpandListener;
 
+    @Mock
+    private PendingIntent mDeleteIntent;
+
     private BubbleData mBubbleData;
 
     @Before
@@ -98,9 +104,9 @@
 
         // Need notifications for bubbles
         mNotificationTestHelper = new NotificationTestHelper(mContext);
-        mRow = mNotificationTestHelper.createBubble();
-        mRow2 = mNotificationTestHelper.createBubble();
-        mNoChannelRow = mNotificationTestHelper.createBubble();
+        mRow = mNotificationTestHelper.createBubble(mDeleteIntent);
+        mRow2 = mNotificationTestHelper.createBubble(mDeleteIntent);
+        mNoChannelRow = mNotificationTestHelper.createBubble(mDeleteIntent);
 
         // Return non-null notification data from the NEM
         when(mNotificationEntryManager.getNotificationData()).thenReturn(mNotificationData);
@@ -141,11 +147,10 @@
 
         verify(mBubbleStateChangeListener).onHasBubblesChanged(true);
 
-        mBubbleController.removeBubble(mRow.getEntry().key);
+        mBubbleController.removeBubble(mRow.getEntry().key, BubbleController.DISMISS_USER_GESTURE);
         assertFalse(mStatusBarWindowController.getBubblesShowing());
         assertTrue(mRow.getEntry().isBubbleDismissed());
         verify(mNotificationEntryManager).updateNotifications();
-
         verify(mBubbleStateChangeListener).onHasBubblesChanged(false);
     }
 
@@ -155,7 +160,7 @@
         mBubbleController.updateBubble(mRow2.getEntry(), true /* updatePosition */);
         assertTrue(mBubbleController.hasBubbles());
 
-        mBubbleController.dismissStack();
+        mBubbleController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
         assertFalse(mStatusBarWindowController.getBubblesShowing());
         verify(mNotificationEntryManager).updateNotifications();
         assertTrue(mRow.getEntry().isBubbleDismissed());
@@ -271,7 +276,8 @@
         assertFalse(mRow2.getEntry().showInShadeWhenBubble());
 
         // Dismiss currently expanded
-        mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey());
+        mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
+                BubbleController.DISMISS_USER_GESTURE);
         verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().key);
 
         // Make sure next bubble is selected
@@ -279,7 +285,8 @@
         verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().key);
 
         // Dismiss that one
-        mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey());
+        mBubbleController.removeBubble(stackView.getExpandedBubbleView().getKey(),
+                BubbleController.DISMISS_USER_GESTURE);
 
         // Make sure state changes and collapse happens
         verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow.getEntry().key);
@@ -299,6 +306,28 @@
         assertTrue(mRow.getEntry().showInShadeWhenBubble());
     }
 
+    @Test
+    public void testDeleteIntent_removeBubble_aged() throws PendingIntent.CanceledException {
+        mBubbleController.updateBubble(mRow.getEntry(), true /* updatePosition */);
+        mBubbleController.removeBubble(mRow.getEntry().key, BubbleController.DISMISS_AGED);
+        verify(mDeleteIntent, never()).send();
+    }
+
+    @Test
+    public void testDeleteIntent_removeBubble_user() throws PendingIntent.CanceledException {
+        mBubbleController.updateBubble(mRow.getEntry(), true /* updatePosition */);
+        mBubbleController.removeBubble(mRow.getEntry().key, BubbleController.DISMISS_USER_GESTURE);
+        verify(mDeleteIntent, times(1)).send();
+    }
+
+    @Test
+    public void testDeleteIntent_dismissStack() throws PendingIntent.CanceledException {
+        mBubbleController.updateBubble(mRow.getEntry(), true /* updatePosition */);
+        mBubbleController.updateBubble(mRow2.getEntry(), true /* updatePosition */);
+        mBubbleController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
+        verify(mDeleteIntent, times(2)).send();
+    }
+
     static class TestableBubbleController extends BubbleController {
 
         TestableBubbleController(Context context,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java
index cef78db..de15505 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationTestHelper.java
@@ -23,6 +23,7 @@
 import android.app.ActivityManager;
 import android.app.Instrumentation;
 import android.app.Notification;
+import android.app.Notification.BubbleMetadata;
 import android.app.NotificationChannel;
 import android.app.PendingIntent;
 import android.content.Context;
@@ -152,8 +153,18 @@
      * Returns an {@link ExpandableNotificationRow} that should be shown as a bubble.
      */
     public ExpandableNotificationRow createBubble() throws Exception {
+        return createBubble(null);
+    }
+
+    /**
+     * Returns an {@link ExpandableNotificationRow} that should be shown as a bubble.
+     *
+     * @param deleteIntent the intent to assign to {@link BubbleMetadata#deleteIntent}
+     */
+    public ExpandableNotificationRow createBubble(@Nullable PendingIntent deleteIntent)
+            throws Exception {
         Notification n = createNotification(false /* isGroupSummary */,
-                null /* groupKey */, true /* isBubble */);
+                null /* groupKey */, true /* isBubble */, deleteIntent);
         return generateRow(n, PKG, UID, USER_HANDLE, 0 /* extraInflationFlags */, IMPORTANCE_HIGH);
     }
 
@@ -196,7 +207,8 @@
      * @return a notification that is in the group specified or standalone if unspecified
      */
     private Notification createNotification(boolean isGroupSummary, @Nullable String groupKey) {
-        return createNotification(isGroupSummary, groupKey, false /* isBubble */);
+        return createNotification(isGroupSummary, groupKey, false /* isBubble */,
+                null /* bubbleDeleteIntent */);
     }
 
     /**
@@ -208,7 +220,8 @@
      * @return a notification that is in the group specified or standalone if unspecified
      */
     private Notification createNotification(boolean isGroupSummary,
-            @Nullable String groupKey, boolean isBubble) {
+            @Nullable String groupKey, boolean isBubble,
+            @Nullable PendingIntent bubbleDeleteIntent) {
         Notification publicVersion = new Notification.Builder(mContext).setSmallIcon(
                 R.drawable.ic_person)
                 .setCustomContentView(new RemoteViews(mContext.getPackageName(),
@@ -227,7 +240,8 @@
             notificationBuilder.setGroup(groupKey);
         }
         if (isBubble) {
-            notificationBuilder.setBubbleMetadata(makeBubbleMetadata());
+            BubbleMetadata metadata = makeBubbleMetadata(bubbleDeleteIntent);
+            notificationBuilder.setBubbleMetadata(metadata);
         }
         return notificationBuilder.build();
     }
@@ -291,11 +305,13 @@
         return row;
     }
 
-    private Notification.BubbleMetadata makeBubbleMetadata() {
+    private BubbleMetadata makeBubbleMetadata(PendingIntent deleteIntent) {
         Intent target = new Intent(mContext, BubblesTestActivity.class);
         PendingIntent bubbleIntent = PendingIntent.getActivity(mContext, 0, target, 0);
-        return new Notification.BubbleMetadata.Builder()
+
+        return new BubbleMetadata.Builder()
                 .setIntent(bubbleIntent)
+                .setDeleteIntent(deleteIntent)
                 .setTitle("bubble title")
                 .setIcon(Icon.createWithResource(mContext, 1))
                 .setDesiredHeight(314)