diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java index 7426dc7259..83d4df29c8 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java @@ -33,7 +33,6 @@ import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_S 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; @@ -463,15 +462,6 @@ public class BubbleBarController extends IBubblesListener.Stub { /** Tells WMShell to show the currently selected bubble. */ public void showSelectedBubble() { if (getSelectedBubbleKey() != null) { - if (mSelectedBubble instanceof BubbleBarBubble) { - // 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 */); - } mLastSentBubbleBarTop = mBarView.getRestingTopPositionOnScreen(); mSystemUiProxy.showBubble(getSelectedBubbleKey(), mLastSentBubbleBarTop); } else { diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java index fd989b106a..4794dfd8db 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java @@ -240,6 +240,10 @@ public class BubbleBarView extends FrameLayout { BubbleView firstBubble = (BubbleView) getChildAt(0); mUpdateSelectedBubbleAfterCollapse.accept(firstBubble.getBubble().getKey()); } + // If the bar was just expanded, remove the dot from the selected bubble. + if (mIsBarExpanded && mSelectedBubbleView != null) { + mSelectedBubbleView.markSeen(); + } updateWidth(); }, /* onUpdate= */ animator -> { @@ -665,7 +669,7 @@ public class BubbleBarView extends FrameLayout { } /** Add a new bubble to the bubble bar. */ - public void addBubble(View bubble) { + public void addBubble(BubbleView bubble) { FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams((int) mIconSize, (int) mIconSize, Gravity.LEFT); if (isExpanded()) { @@ -673,6 +677,7 @@ public class BubbleBarView extends FrameLayout { bubble.setScaleX(0f); bubble.setScaleY(0f); addView(bubble, 0, lp); + bubble.showDotIfNeeded(/* animate= */ false); mBubbleAnimator = new BubbleAnimator(mIconSize, mExpandedBarIconsSpacing, getChildCount(), mBubbleBarLocation.isOnLeft(isLayoutRtl())); @@ -825,6 +830,23 @@ public class BubbleBarView extends FrameLayout { updateBubbleAccessibilityStates(); updateContentDescription(); mDismissedByDragBubbleView = null; + updateNotificationDotsIfCollapsed(); + } + + private void updateNotificationDotsIfCollapsed() { + if (isExpanded()) { + return; + } + for (int i = 0; i < getChildCount(); i++) { + BubbleView bubbleView = (BubbleView) getChildAt(i); + // when we're collapsed, the first bubble should show the dot if it has it. the rest of + // the bubbles should hide their dots. + if (i == 0 && bubbleView.hasUnseenContent()) { + bubbleView.showDotIfNeeded(/* animate= */ true); + } else { + bubbleView.hideDot(); + } + } } private void updateWidth() { @@ -865,7 +887,6 @@ public class BubbleBarView extends FrameLayout { float bubbleBarAnimatedTop = viewBottom - getBubbleBarHeight(); // When translating X & Y the scale is ignored, so need to deduct it from the translations final float ty = bubbleBarAnimatedTop + mBubbleBarPadding - getScaleIconShift(); - final boolean animate = getVisibility() == VISIBLE; final boolean onLeft = bubbleBarLocation.isOnLeft(isLayoutRtl()); // elevation state is opposite to widthState - when expanded all icons are flat float elevationState = (1 - widthState); @@ -897,10 +918,10 @@ public class BubbleBarView extends FrameLayout { bv.setZ(fullElevationForChild * elevationState); // only update the dot scale if we're expanding or collapsing - // TODO b/351904597: update the dot for the first bubble after removal and reorder - // since those might happen when the bar is collapsed and will need their dot back if (mWidthAnimator.isRunning()) { - bv.setDotScale(widthState); + // The dot for the selected bubble scales in the opposite direction of the expansion + // animation. + bv.showDotIfNeeded(bv == mSelectedBubbleView ? 1 - widthState : widthState); } if (mIsBarExpanded) { @@ -1025,6 +1046,7 @@ public class BubbleBarView extends FrameLayout { } updateBubblesLayoutProperties(mBubbleBarLocation); updateContentDescription(); + updateNotificationDotsIfCollapsed(); } } @@ -1049,6 +1071,14 @@ public class BubbleBarView extends FrameLayout { if (mBubbleAnimator == null) { updateArrowForSelected(previouslySelectedBubble != null); } + if (view != null) { + if (isExpanded()) { + view.markSeen(); + } else { + // when collapsed, the selected bubble should show the dot if it has it + view.showDotIfNeeded(/* animate= */ true); + } + } } /** @@ -1316,6 +1346,7 @@ public class BubbleBarView extends FrameLayout { public void dump(PrintWriter pw) { pw.println("BubbleBarView state:"); pw.println(" visibility: " + getVisibility()); + pw.println(" alpha: " + getAlpha()); pw.println(" translation Y: " + getTranslationY()); pw.println(" bubbles in bar (childCount = " + getChildCount() + ")"); for (BubbleView bubbleView: getBubbles()) { diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java index 24b91395f8..1c33be624a 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java @@ -115,7 +115,7 @@ public class BubbleBarViewController { dp -> updateBubbleBarIconSize(dp.taskbarIconSize, /* animate= */ true)); updateBubbleBarIconSize(mActivity.getDeviceProfile().taskbarIconSize, /* animate= */ false); mBubbleBarScale.updateValue(1f); - mBubbleClickListener = v -> onBubbleClicked(v); + mBubbleClickListener = v -> onBubbleClicked((BubbleView) v); mBubbleBarClickListener = v -> onBubbleBarClicked(); mBubbleDragController.setupBubbleBarView(mBarView); mBarView.setOnClickListener(mBubbleBarClickListener); @@ -139,8 +139,9 @@ public class BubbleBarViewController { }); } - private void onBubbleClicked(View v) { - BubbleBarItem bubble = ((BubbleView) v).getBubble(); + private void onBubbleClicked(BubbleView bubbleView) { + bubbleView.markSeen(); + BubbleBarItem bubble = bubbleView.getBubble(); if (bubble == null) { Log.e(TAG, "bubble click listener, bubble was null"); } diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java index 4c468bbd6d..acb6b4ecfe 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java @@ -16,6 +16,7 @@ package com.android.launcher3.taskbar.bubbles; import android.annotation.Nullable; +import android.app.Notification; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Canvas; @@ -35,6 +36,7 @@ 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 com.android.wm.shell.common.bubbles.BubbleInfo; // TODO: (b/276978250) This is will be similar to WMShell's BadgedImageView, it'd be nice to share. @@ -217,9 +219,9 @@ public class BubbleView extends ConstraintLayout { } void updateDotVisibility(boolean animate) { - final float targetScale = shouldDrawDot() ? 1f : 0f; + final float targetScale = hasUnseenContent() ? 1f : 0f; if (animate) { - animateDotScale(); + animateDotScale(targetScale); } else { mDotScale = targetScale; mAnimatingToDotScale = targetScale; @@ -241,18 +243,27 @@ public class BubbleView extends ConstraintLayout { mAppIcon.setVisibility(show ? VISIBLE : GONE); } - /** Whether the dot indicating unseen content in a bubble should be shown. */ - private boolean shouldDrawDot() { - boolean bubbleHasUnseenContent = mBubble != null + boolean hasUnseenContent() { + return mBubble != null && mBubble instanceof BubbleBarBubble && !((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. */ - void setDotScale(float fraction) { + /** + * Used to determine if we can skip drawing frames. + * + *

Generally we should draw the dot when it is requested to be shown and there is unseen + * content. But when the dot is removed, we still want to draw frames so that it can be scaled + * out. + */ + private boolean shouldDrawDot() { + // if there's no dot there's nothing to draw, unless the dot was removed and we're in the + // middle of removing it + return hasUnseenContent() || mDotIsAnimating; + } + + /** Updates the dot scale to the specified fraction from 0 to 1. */ + private void setDotScale(float fraction) { if (!shouldDrawDot()) { return; } @@ -260,11 +271,41 @@ public class BubbleView extends ConstraintLayout { invalidate(); } - /** - * Animates the dot to the given scale. - */ - private void animateDotScale() { - float toScale = shouldDrawDot() ? 1f : 0f; + void showDotIfNeeded(float fraction) { + if (!hasUnseenContent()) { + return; + } + setDotScale(fraction); + } + + void showDotIfNeeded(boolean animate) { + // only show the dot if we have unseen content + if (!hasUnseenContent()) { + return; + } + if (animate) { + animateDotScale(1f); + } else { + setDotScale(1f); + } + } + + void hideDot() { + animateDotScale(0f); + } + + /** Marks this bubble such that it no longer has unseen content, and hides the dot. */ + void markSeen() { + if (mBubble instanceof BubbleBarBubble bubble) { + BubbleInfo info = bubble.getInfo(); + info.setFlags( + info.getFlags() | Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); + hideDot(); + } + } + + /** Animates the dot to the given scale. */ + private void animateDotScale(float toScale) { boolean isDotScaleChanging = Float.compare(mDotScale, toScale) != 0; // Don't restart the animation if we're already animating to the given value or if the dot @@ -277,8 +318,6 @@ public class BubbleView extends ConstraintLayout { 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) @@ -293,7 +332,6 @@ public class BubbleView extends ConstraintLayout { }).start(); } - @Override public String toString() { String toString = mBubble != null ? mBubble.getKey() : "null"; diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleViewTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleViewTest.kt new file mode 100644 index 0000000000..8bad3b9569 --- /dev/null +++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleViewTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 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.launcher3.taskbar.bubbles + +import android.content.Context +import android.graphics.Color +import android.graphics.Path +import android.graphics.drawable.ColorDrawable +import android.view.LayoutInflater +import androidx.core.graphics.drawable.toBitmap +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.launcher3.R +import com.android.wm.shell.common.bubbles.BubbleInfo +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class BubbleViewTest { + + private val context = ApplicationProvider.getApplicationContext() + private lateinit var bubbleView: BubbleView + private lateinit var overflowView: BubbleView + private lateinit var bubble: BubbleBarBubble + + @Test + fun hasUnseenContent_bubble() { + setupBubbleViews() + assertThat(bubbleView.hasUnseenContent()).isTrue() + + bubbleView.markSeen() + assertThat(bubbleView.hasUnseenContent()).isFalse() + } + + @Test + fun hasUnseenContent_overflow() { + setupBubbleViews() + assertThat(overflowView.hasUnseenContent()).isFalse() + } + + private fun setupBubbleViews() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val inflater = LayoutInflater.from(context) + + val bitmap = ColorDrawable(Color.WHITE).toBitmap(width = 20, height = 20) + overflowView = inflater.inflate(R.layout.bubblebar_item_view, null, false) as BubbleView + overflowView.setOverflow(BubbleBarOverflow(overflowView), bitmap) + + val bubbleInfo = + BubbleInfo("key", 0, null, null, 0, context.packageName, null, null, false) + bubbleView = inflater.inflate(R.layout.bubblebar_item_view, null, false) as BubbleView + bubble = + BubbleBarBubble(bubbleInfo, bubbleView, bitmap, bitmap, Color.WHITE, Path(), "") + bubbleView.setBubble(bubble) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } +}