Show / hide the "update" dot on bubbles in bubble bar

Updates BubbleView to include logic to render the update dot on a
bubble. This only shows for BubbleBarBubbles, not the overflow.

We only show the dot (and the badge) when:
- the bubble has new content / appropriate flags set
- AND the bubbles are expanded
  OR on the first bubble when bubbles are collapsed
- AND when the flyout is not animating (this bit doesn't exist yet)

If a bubble has a dot and is opened, the dot will animate away.
To do this, we update the flags set on a bubble.

The flag needs to be set on WMShell side as well as Launcher side.
When a bubble is shown by WMShell, it automatically updates the flag.
This CL adds code to update the flag on Launcher side when we call
into WMShell to show the bubble.

Test: manual
Bug: 269670235
Change-Id: I32f652effa9a73c567981aa5a2a5864e9c3c0c66
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
index 029c23f..7e2b037 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
@@ -33,6 +33,7 @@
 
 import android.annotation.BinderThread;
 import android.annotation.Nullable;
+import android.app.Notification;
 import android.content.Context;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.LauncherApps;
@@ -319,9 +320,11 @@
         mBubbleStashedHandleViewController.setHiddenForBubbles(mBubbles.isEmpty());
 
         if (update.updatedBubble != null) {
-            // TODO: (b/269670235) handle updates:
-            //  (1) if content / icons change -- requires reload & add back in place
-            //  (2) if showing update dot changes -- tell the view to hide / show the dot
+            // Updates mean the dot state may have changed; any other changes were updated in
+            // the populateBubble step.
+            BubbleBarBubble bb = mBubbles.get(update.updatedBubble.getKey());
+            // If we're not stashed, we're visible so animate
+            bb.getView().updateDotVisibility(!mBubbleStashController.isStashed() /* animate */);
         }
         if (update.bubbleKeysInOrder != null && !update.bubbleKeysInOrder.isEmpty()) {
             // Create the new list
@@ -366,7 +369,13 @@
         if (getSelectedBubbleKey() != null) {
             int[] bubbleBarCoords = mBarView.getLocationOnScreen();
             if (mSelectedBubble instanceof BubbleBarBubble) {
-                // TODO (b/269670235): hide the update dot on the view if needed.
+                // Because we've visited this bubble, we should suppress the notification.
+                // This is updated on WMShell side when we show the bubble, but that update isn't
+                // passed to launcher, instead we apply it directly here.
+                BubbleInfo info = ((BubbleBarBubble) mSelectedBubble).getInfo();
+                info.setFlags(
+                        info.getFlags() | Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION);
+                mSelectedBubble.getView().updateDotVisibility(true /* animate */);
             }
             mSystemUiProxy.showBubble(getSelectedBubbleKey(),
                     bubbleBarCoords[0], bubbleBarCoords[1]);
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
index 563ba02..cf52a5e 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
@@ -234,6 +234,7 @@
         final float collapsedWidth = collapsedWidth();
         int bubbleCount = getChildCount();
         final float ty = (mBubbleBarBounds.height() - mIconSize) / 2f;
+        final boolean animate = getVisibility() == VISIBLE;
         for (int i = 0; i < bubbleCount; i++) {
             BubbleView bv = (BubbleView) getChildAt(i);
             bv.setTranslationY(ty);
@@ -251,16 +252,14 @@
                 if (widthState == 1f) {
                     bv.setZ(0);
                 }
-                bv.showBadge();
+                // When we're expanded, we're not stacked so we're not behind the stack
+                bv.setBehindStack(false, animate);
             } else {
                 final float targetX = currentWidth - collapsedWidth + collapsedX;
                 bv.setTranslationX(widthState * (expandedX - targetX) + targetX);
                 bv.setZ((MAX_BUBBLES * mBubbleElevation) - i);
-                if (i > 0) {
-                    bv.hideBadge();
-                } else {
-                    bv.showBadge();
-                }
+                // If we're not the first bubble we're behind the stack
+                bv.setBehindStack(i > 0, animate);
             }
         }
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
index 92b76a6..12cb8c5 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
@@ -18,7 +18,9 @@
 import android.annotation.Nullable;
 import android.content.Context;
 import android.graphics.Bitmap;
+import android.graphics.Canvas;
 import android.graphics.Outline;
+import android.graphics.Rect;
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -28,10 +30,13 @@
 import androidx.constraintlayout.widget.ConstraintLayout;
 
 import com.android.launcher3.R;
+import com.android.launcher3.icons.DotRenderer;
 import com.android.launcher3.icons.IconNormalizer;
+import com.android.wm.shell.animation.Interpolators;
+
+import java.util.EnumSet;
 
 // TODO: (b/276978250) This is will be similar to WMShell's BadgedImageView, it'd be nice to share.
-// TODO: (b/269670235) currently this doesn't show the 'update dot'
 
 /**
  * View that displays a bubble icon, along with an app badge on either the left or
@@ -39,14 +44,42 @@
  */
 public class BubbleView extends ConstraintLayout {
 
-    // TODO: (b/269670235) currently we don't render the 'update dot', this will be used for that.
     public static final int DEFAULT_PATH_SIZE = 100;
 
+    /**
+     * Flags that suppress the visibility of the 'new' dot or the app badge, for one reason or
+     * another. If any of these flags are set, the dot will not be shown.
+     * If {@link SuppressionFlag#BEHIND_STACK} then the app badge will not be shown.
+     */
+    enum SuppressionFlag {
+        // TODO: (b/277815200) implement flyout
+        // Suppressed because the flyout is visible - it will morph into the dot via animation.
+        FLYOUT_VISIBLE,
+        // Suppressed because this bubble is behind others in the collapsed stack.
+        BEHIND_STACK,
+    }
+
+    private final EnumSet<SuppressionFlag> mSuppressionFlags =
+            EnumSet.noneOf(SuppressionFlag.class);
+
     private final ImageView mBubbleIcon;
     private final ImageView mAppIcon;
     private final int mBubbleSize;
 
+    private DotRenderer mDotRenderer;
+    private DotRenderer.DrawParams mDrawParams;
+    private int mDotColor;
+    private Rect mTempBounds = new Rect();
+
+    // Whether the dot is animating
+    private boolean mDotIsAnimating;
+    // What scale value the dot is animating to
+    private float mAnimatingToDotScale;
+    // The current scale value of the dot
+    private float mDotScale;
+
     // TODO: (b/273310265) handle RTL
+    // Whether the bubbles are positioned on the left or right side of the screen
     private boolean mOnLeft = false;
 
     private BubbleBarItem mBubble;
@@ -75,6 +108,8 @@
         mBubbleIcon = findViewById(R.id.icon_view);
         mAppIcon = findViewById(R.id.app_icon_view);
 
+        mDrawParams = new DotRenderer.DrawParams();
+
         setFocusable(true);
         setClickable(true);
         setOutlineProvider(new ViewOutlineProvider() {
@@ -91,17 +126,43 @@
         outline.setOval(inset, inset, inset + normalizedSize, inset + normalizedSize);
     }
 
+    @Override
+    public void dispatchDraw(Canvas canvas) {
+        super.dispatchDraw(canvas);
+
+        if (!shouldDrawDot()) {
+            return;
+        }
+
+        getDrawingRect(mTempBounds);
+
+        mDrawParams.dotColor = mDotColor;
+        mDrawParams.iconBounds = mTempBounds;
+        mDrawParams.leftAlign = mOnLeft;
+        mDrawParams.scale = mDotScale;
+
+        mDotRenderer.draw(canvas, mDrawParams);
+    }
+
     /** Sets the bubble being rendered in this view. */
     void setBubble(BubbleBarBubble bubble) {
         mBubble = bubble;
         mBubbleIcon.setImageBitmap(bubble.getIcon());
         mAppIcon.setImageBitmap(bubble.getBadge());
+        mDotColor = bubble.getDotColor();
+        mDotRenderer = new DotRenderer(mBubbleSize, bubble.getDotPath(), DEFAULT_PATH_SIZE);
     }
 
+    /**
+     * Sets that this bubble represents the overflow. The overflow appears in the list of bubbles
+     * but does not represent app content, instead it shows recent bubbles that couldn't fit into
+     * the list of bubbles. It doesn't show an app icon because it is part of system UI / doesn't
+     * come from an app.
+     */
     void setOverflow(BubbleBarOverflow overflow, Bitmap bitmap) {
         mBubble = overflow;
         mBubbleIcon.setImageBitmap(bitmap);
-        hideBadge();
+        mAppIcon.setVisibility(GONE); // Overflow doesn't show the app badge
     }
 
     /** Returns the bubble being rendered in this view. */
@@ -110,38 +171,102 @@
         return mBubble;
     }
 
-    /** Shows the app badge on this bubble. */
-    void showBadge() {
+    void updateDotVisibility(boolean animate) {
+        final float targetScale = shouldDrawDot() ? 1f : 0f;
+        if (animate) {
+            animateDotScale();
+        } else {
+            mDotScale = targetScale;
+            mAnimatingToDotScale = targetScale;
+            invalidate();
+        }
+    }
+
+    void updateBadgeVisibility() {
         if (mBubble instanceof BubbleBarOverflow) {
             // The overflow bubble does not have a badge, so just bail.
             return;
         }
         BubbleBarBubble bubble = (BubbleBarBubble) mBubble;
-
         Bitmap appBadgeBitmap = bubble.getBadge();
-        if (appBadgeBitmap == null) {
-            mAppIcon.setVisibility(GONE);
+        int translationX = mOnLeft
+                ? -(bubble.getIcon().getWidth() - appBadgeBitmap.getWidth())
+                : 0;
+        mAppIcon.setTranslationX(translationX);
+        mAppIcon.setVisibility(isBehindStack() ? GONE : VISIBLE);
+    }
+
+    /** Sets whether this bubble is in the stack & not the first bubble. **/
+    void setBehindStack(boolean behindStack, boolean animate) {
+        if (behindStack) {
+            mSuppressionFlags.add(SuppressionFlag.BEHIND_STACK);
+        } else {
+            mSuppressionFlags.remove(SuppressionFlag.BEHIND_STACK);
+        }
+        updateDotVisibility(animate);
+        updateBadgeVisibility();
+    }
+
+    /** Whether this bubble is in the stack & not the first bubble. **/
+    boolean isBehindStack() {
+        return mSuppressionFlags.contains(SuppressionFlag.BEHIND_STACK);
+    }
+
+    /** Whether the dot indicating unseen content in a bubble should be shown. */
+    private boolean shouldDrawDot() {
+        boolean bubbleHasUnseenContent = mBubble != null
+                && mBubble instanceof BubbleBarBubble
+                && mSuppressionFlags.isEmpty()
+                && !((BubbleBarBubble) mBubble).getInfo().isNotificationSuppressed();
+
+        // Always render the dot if it's animating, since it could be animating out. Otherwise, show
+        // it if the bubble wants to show it, and we aren't suppressing it.
+        return bubbleHasUnseenContent || mDotIsAnimating;
+    }
+
+    /** How big the dot should be, fraction from 0 to 1. */
+    private void setDotScale(float fraction) {
+        mDotScale = fraction;
+        invalidate();
+    }
+
+    /**
+     * Animates the dot to the given scale.
+     */
+    private void animateDotScale() {
+        float toScale = shouldDrawDot() ? 1f : 0f;
+        mDotIsAnimating = true;
+
+        // Don't restart the animation if we're already animating to the given value.
+        if (mAnimatingToDotScale == toScale || !shouldDrawDot()) {
+            mDotIsAnimating = false;
             return;
         }
 
-        int translationX;
-        if (mOnLeft) {
-            translationX = -(bubble.getIcon().getWidth() - appBadgeBitmap.getWidth());
-        } else {
-            translationX = 0;
-        }
+        mAnimatingToDotScale = toScale;
 
-        mAppIcon.setTranslationX(translationX);
-        mAppIcon.setVisibility(VISIBLE);
+        final boolean showDot = toScale > 0f;
+
+        // Do NOT wait until after animation ends to setShowDot
+        // to avoid overriding more recent showDot states.
+        clearAnimation();
+        animate()
+                .setDuration(200)
+                .setInterpolator(Interpolators.FAST_OUT_SLOW_IN)
+                .setUpdateListener((valueAnimator) -> {
+                    float fraction = valueAnimator.getAnimatedFraction();
+                    fraction = showDot ? fraction : 1f - fraction;
+                    setDotScale(fraction);
+                }).withEndAction(() -> {
+                    setDotScale(showDot ? 1f : 0f);
+                    mDotIsAnimating = false;
+                }).start();
     }
 
-    /** Hides the app badge on this bubble. */
-    void hideBadge() {
-        mAppIcon.setVisibility(GONE);
-    }
 
     @Override
     public String toString() {
-        return "BubbleView{" + mBubble + "}";
+        String toString = mBubble != null ? mBubble.getKey() : "null";
+        return "BubbleView{" + toString + "}";
     }
 }