/* * Copyright (C) 2012 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.systemui.bubbles; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; import static com.android.systemui.Interpolators.FAST_OUT_SLOW_IN; import static com.android.systemui.Prefs.Key.HAS_SEEN_BUBBLES_EDUCATION; import static com.android.systemui.Prefs.Key.HAS_SEEN_BUBBLES_MANAGE_EDUCATION; import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_STACK_VIEW; import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_USER_EDUCATION; import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES; import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.app.ActivityView; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; import android.graphics.Outline; import android.graphics.Paint; import android.graphics.Point; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Region; import android.graphics.drawable.TransitionDrawable; import android.os.Bundle; import android.os.Handler; import android.provider.Settings; import android.util.Log; import android.view.Choreographer; import android.view.DisplayCutout; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.SurfaceControl; import android.view.SurfaceView; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.WindowManager; import android.view.accessibility.AccessibilityNodeInfo; import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.dynamicanimation.animation.DynamicAnimation; import androidx.dynamicanimation.animation.FloatPropertyCompat; import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.util.ContrastColorUtil; import com.android.systemui.Interpolators; import com.android.systemui.Prefs; import com.android.systemui.R; import com.android.systemui.bubbles.animation.AnimatableScaleMatrix; import com.android.systemui.bubbles.animation.ExpandedAnimationController; import com.android.systemui.bubbles.animation.PhysicsAnimationLayout; import com.android.systemui.bubbles.animation.StackAnimationController; import com.android.systemui.model.SysUiState; import com.android.systemui.shared.system.QuickStepContract; import com.android.systemui.shared.system.SysUiStatsLog; import com.android.systemui.statusbar.phone.CollapsedStatusBarFragment; import com.android.systemui.util.DismissCircleView; import com.android.systemui.util.FloatingContentCoordinator; import com.android.systemui.util.RelativeTouchListener; import com.android.systemui.util.animation.PhysicsAnimator; import com.android.systemui.util.magnetictarget.MagnetizedObject; import java.io.FileDescriptor; import java.io.PrintWriter; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.function.Consumer; /** * Renders bubbles in a stack and handles animating expanded and collapsed states. */ public class BubbleStackView extends FrameLayout implements ViewTreeObserver.OnComputeInternalInsetsListener { private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleStackView" : TAG_BUBBLES; /** Animation durations for bubble stack user education views. **/ private static final int ANIMATE_STACK_USER_EDUCATION_DURATION = 200; private static final int ANIMATE_STACK_USER_EDUCATION_DURATION_SHORT = 40; /** How far the flyout needs to be dragged before it's dismissed regardless of velocity. */ static final float FLYOUT_DRAG_PERCENT_DISMISS = 0.25f; /** Velocity required to dismiss the flyout via drag. */ private static final float FLYOUT_DISMISS_VELOCITY = 2000f; /** * Factor for attenuating translation when the flyout is overscrolled (8f = flyout moves 1 pixel * for every 8 pixels overscrolled). */ private static final float FLYOUT_OVERSCROLL_ATTENUATION_FACTOR = 8f; /** Duration of the flyout alpha animations. */ private static final int FLYOUT_ALPHA_ANIMATION_DURATION = 100; /** Percent to darken the bubbles when they're in the dismiss target. */ private static final float DARKEN_PERCENT = 0.3f; /** Duration of the dismiss scrim fading in/out. */ private static final int DISMISS_TRANSITION_DURATION_MS = 200; /** How long to wait, in milliseconds, before hiding the flyout. */ @VisibleForTesting static final int FLYOUT_HIDE_AFTER = 5000; /** * How long to wait to animate the stack temporarily invisible after a drag/flyout hide * animation ends, if we are in fact temporarily invisible. */ private static final int ANIMATE_TEMPORARILY_INVISIBLE_DELAY = 1000; private static final PhysicsAnimator.SpringConfig FLYOUT_IME_ANIMATION_SPRING_CONFIG = new PhysicsAnimator.SpringConfig( StackAnimationController.IME_ANIMATION_STIFFNESS, StackAnimationController.DEFAULT_BOUNCINESS); private final PhysicsAnimator.SpringConfig mScaleInSpringConfig = new PhysicsAnimator.SpringConfig(300f, 0.9f); private final PhysicsAnimator.SpringConfig mScaleOutSpringConfig = new PhysicsAnimator.SpringConfig(900f, 1f); private final PhysicsAnimator.SpringConfig mTranslateSpringConfig = new PhysicsAnimator.SpringConfig( SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_NO_BOUNCY); /** * Handler to use for all delayed animations - this way, we can easily cancel them before * starting a new animation. */ private final Handler mDelayedAnimationHandler = new Handler(); /** * Interface to synchronize {@link View} state and the screen. * * {@hide} */ interface SurfaceSynchronizer { /** * Wait until requested change on a {@link View} is reflected on the screen. * * @param callback callback to run after the change is reflected on the screen. */ void syncSurfaceAndRun(Runnable callback); } private static final SurfaceSynchronizer DEFAULT_SURFACE_SYNCHRONIZER = new SurfaceSynchronizer() { @Override public void syncSurfaceAndRun(Runnable callback) { Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { // Just wait 2 frames. There is no guarantee, but this is usually enough time that // the requested change is reflected on the screen. // TODO: Once SurfaceFlinger provide APIs to sync the state of {@code View} and // surfaces, rewrite this logic with them. private int mFrameWait = 2; @Override public void doFrame(long frameTimeNanos) { if (--mFrameWait > 0) { Choreographer.getInstance().postFrameCallback(this); } else { callback.run(); } } }); } }; private Point mDisplaySize; private final BubbleData mBubbleData; private final ValueAnimator mDesaturateAndDarkenAnimator; private final Paint mDesaturateAndDarkenPaint = new Paint(); private PhysicsAnimationLayout mBubbleContainer; private StackAnimationController mStackAnimationController; private ExpandedAnimationController mExpandedAnimationController; private FrameLayout mExpandedViewContainer; /** Matrix used to scale the expanded view container with a given pivot point. */ private final AnimatableScaleMatrix mExpandedViewContainerMatrix = new AnimatableScaleMatrix(); /** * SurfaceView that we draw screenshots of animating-out bubbles into. This allows us to animate * between bubble activities without needing both to be alive at the same time. */ private SurfaceView mAnimatingOutSurfaceView; /** Container for the animating-out SurfaceView. */ private FrameLayout mAnimatingOutSurfaceContainer; /** * Buffer containing a screenshot of the animating-out bubble. This is drawn into the * SurfaceView during animations. */ private SurfaceControl.ScreenshotGraphicBuffer mAnimatingOutBubbleBuffer; private BubbleFlyoutView mFlyout; /** Runnable that fades out the flyout and then sets it to GONE. */ private Runnable mHideFlyout = () -> animateFlyoutCollapsed(true, 0 /* velX */); /** * Callback to run after the flyout hides. Also called if a new flyout is shown before the * previous one animates out. */ private Runnable mAfterFlyoutHidden; /** * Set when the flyout is tapped, so that we can expand the bubble associated with the flyout * once it collapses. */ @Nullable private Bubble mBubbleToExpandAfterFlyoutCollapse = null; /** Layout change listener that moves the stack to the nearest valid position on rotation. */ private OnLayoutChangeListener mOrientationChangedListener; /** Whether the stack was on the left side of the screen prior to rotation. */ private boolean mWasOnLeftBeforeRotation = false; /** * How far down the screen the stack was before rotation, in terms of percentage of the way down * the allowable region. Defaults to -1 if not set. */ private float mVerticalPosPercentBeforeRotation = -1; private int mMaxBubbles; private int mBubbleSize; private int mBubbleElevation; private int mBubblePaddingTop; private int mBubbleTouchPadding; private int mExpandedViewPadding; private int mCornerRadius; private int mStatusBarHeight; private int mImeOffset; @Nullable private BubbleViewProvider mExpandedBubble; private boolean mIsExpanded; /** Whether the stack is currently on the left side of the screen, or animating there. */ private boolean mStackOnLeftOrWillBe = true; /** Whether a touch gesture, such as a stack/bubble drag or flyout drag, is in progress. */ private boolean mIsGestureInProgress = false; /** Whether or not the stack is temporarily invisible off the side of the screen. */ private boolean mTemporarilyInvisible = false; /** Whether we're in the middle of dragging the stack around by touch. */ private boolean mIsDraggingStack = false; /** * The pointer index of the ACTION_DOWN event we received prior to an ACTION_UP. We'll ignore * touches from other pointer indices. */ private int mPointerIndexDown = -1; /** Description of current animation controller state. */ public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { pw.println("Stack view state:"); pw.print(" gestureInProgress: "); pw.println(mIsGestureInProgress); pw.print(" showingDismiss: "); pw.println(mShowingDismiss); pw.print(" isExpansionAnimating: "); pw.println(mIsExpansionAnimating); pw.print(" expandedContainerVis: "); pw.println(mExpandedViewContainer.getVisibility()); pw.print(" expandedContainerAlpha: "); pw.println(mExpandedViewContainer.getAlpha()); pw.print(" expandedContainerMatrix: "); pw.println(mExpandedViewContainer.getAnimationMatrix()); mStackAnimationController.dump(fd, pw, args); mExpandedAnimationController.dump(fd, pw, args); if (mExpandedBubble != null) { pw.println("Expanded bubble state:"); pw.println(" expandedBubbleKey: " + mExpandedBubble.getKey()); final BubbleExpandedView expandedView = mExpandedBubble.getExpandedView(); if (expandedView != null) { pw.println(" expandedViewVis: " + expandedView.getVisibility()); pw.println(" expandedViewAlpha: " + expandedView.getAlpha()); pw.println(" expandedViewTaskId: " + expandedView.getTaskId()); final ActivityView av = expandedView.getActivityView(); if (av != null) { pw.println(" activityViewVis: " + av.getVisibility()); pw.println(" activityViewAlpha: " + av.getAlpha()); } else { pw.println(" activityView is null"); } } else { pw.println("Expanded bubble view state: expanded bubble view is null"); } } else { pw.println("Expanded bubble state: expanded bubble is null"); } } private BubbleController.BubbleExpandListener mExpandListener; /** Callback to run when we want to unbubble the given notification's conversation. */ private Consumer mUnbubbleConversationCallback; private SysUiState mSysUiState; private boolean mViewUpdatedRequested = false; private boolean mIsExpansionAnimating = false; private boolean mIsBubbleSwitchAnimating = false; private boolean mShowingDismiss = false; /** The view to desaturate/darken when magneted to the dismiss target. */ @Nullable private View mDesaturateAndDarkenTargetView; private LayoutInflater mInflater; private Rect mTempRect = new Rect(); private final List mSystemGestureExclusionRects = Collections.singletonList(new Rect()); private ViewTreeObserver.OnPreDrawListener mViewUpdater = new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { getViewTreeObserver().removeOnPreDrawListener(mViewUpdater); updateExpandedView(); mViewUpdatedRequested = false; return true; } }; private ViewTreeObserver.OnDrawListener mSystemGestureExcludeUpdater = this::updateSystemGestureExcludeRects; /** Float property that 'drags' the flyout. */ private final FloatPropertyCompat mFlyoutCollapseProperty = new FloatPropertyCompat("FlyoutCollapseSpring") { @Override public float getValue(Object o) { return mFlyoutDragDeltaX; } @Override public void setValue(Object o, float v) { setFlyoutStateForDragLength(v); } }; /** SpringAnimation that springs the flyout collapsed via onFlyoutDragged. */ private final SpringAnimation mFlyoutTransitionSpring = new SpringAnimation(this, mFlyoutCollapseProperty); /** Distance the flyout has been dragged in the X axis. */ private float mFlyoutDragDeltaX = 0f; /** * Runnable that animates in the flyout. This reference is needed to cancel delayed postings. */ private Runnable mAnimateInFlyout; /** * End listener for the flyout spring that either posts a runnable to hide the flyout, or hides * it immediately. */ private final DynamicAnimation.OnAnimationEndListener mAfterFlyoutTransitionSpring = (dynamicAnimation, b, v, v1) -> { if (mFlyoutDragDeltaX == 0) { mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER); } else { mFlyout.hideFlyout(); } }; @NonNull private final SurfaceSynchronizer mSurfaceSynchronizer; /** * Callback to run when the IME visibility changes - BubbleController uses this to update the * Bubbles window focusability flags with the WindowManager. */ public final Consumer mOnImeVisibilityChanged; /** * Callback to run to ask BubbleController to hide the current IME. */ private final Runnable mHideCurrentInputMethodCallback; /** * The currently magnetized object, which is being dragged and will be attracted to the magnetic * dismiss target. * * This is either the stack itself, or an individual bubble. */ private MagnetizedObject mMagnetizedObject; /** * The MagneticTarget instance for our circular dismiss view. This is added to the * MagnetizedObject instances for the stack and any dragged-out bubbles. */ private MagnetizedObject.MagneticTarget mMagneticTarget; /** Magnet listener that handles animating and dismissing individual dragged-out bubbles. */ private final MagnetizedObject.MagnetListener mIndividualBubbleMagnetListener = new MagnetizedObject.MagnetListener() { @Override public void onStuckToTarget(@NonNull MagnetizedObject.MagneticTarget target) { if (mExpandedAnimationController.getDraggedOutBubble() == null) { return; } animateDesaturateAndDarken( mExpandedAnimationController.getDraggedOutBubble(), true); } @Override public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target, float velX, float velY, boolean wasFlungOut) { if (mExpandedAnimationController.getDraggedOutBubble() == null) { return; } animateDesaturateAndDarken( mExpandedAnimationController.getDraggedOutBubble(), false); if (wasFlungOut) { mExpandedAnimationController.snapBubbleBack( mExpandedAnimationController.getDraggedOutBubble(), velX, velY); hideDismissTarget(); } else { mExpandedAnimationController.onUnstuckFromTarget(); } } @Override public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) { if (mExpandedAnimationController.getDraggedOutBubble() == null) { return; } mExpandedAnimationController.dismissDraggedOutBubble( mExpandedAnimationController.getDraggedOutBubble() /* bubble */, mDismissTargetContainer.getHeight() /* translationYBy */, BubbleStackView.this::dismissMagnetizedObject /* after */); hideDismissTarget(); } }; /** Magnet listener that handles animating and dismissing the entire stack. */ private final MagnetizedObject.MagnetListener mStackMagnetListener = new MagnetizedObject.MagnetListener() { @Override public void onStuckToTarget( @NonNull MagnetizedObject.MagneticTarget target) { animateDesaturateAndDarken(mBubbleContainer, true); } @Override public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target, float velX, float velY, boolean wasFlungOut) { animateDesaturateAndDarken(mBubbleContainer, false); if (wasFlungOut) { mStackAnimationController.flingStackThenSpringToEdge( mStackAnimationController.getStackPosition().x, velX, velY); hideDismissTarget(); } else { mStackAnimationController.onUnstuckFromTarget(); } } @Override public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) { mStackAnimationController.animateStackDismissal( mDismissTargetContainer.getHeight() /* translationYBy */, () -> { resetDesaturationAndDarken(); dismissMagnetizedObject(); } ); hideDismissTarget(); } }; /** * Click listener set on each bubble view. When collapsed, clicking a bubble expands the stack. * When expanded, clicking a bubble either expands that bubble, or collapses the stack. */ private OnClickListener mBubbleClickListener = new OnClickListener() { @Override public void onClick(View view) { mIsDraggingStack = false; // If the touch ended in a click, we're no longer dragging. // Bubble clicks either trigger expansion/collapse or a bubble switch, both of which we // shouldn't interrupt. These are quick transitions, so it's not worth trying to adjust // the animations inflight. if (mIsExpansionAnimating || mIsBubbleSwitchAnimating) { return; } final Bubble clickedBubble = mBubbleData.getBubbleWithView(view); // If the bubble has since left us, ignore the click. if (clickedBubble == null) { return; } final boolean clickedBubbleIsCurrentlyExpandedBubble = clickedBubble.getKey().equals(mExpandedBubble.getKey()); if (isExpanded()) { mExpandedAnimationController.onGestureFinished(); } if (isExpanded() && !clickedBubbleIsCurrentlyExpandedBubble) { if (clickedBubble != mBubbleData.getSelectedBubble()) { // Select the clicked bubble. mBubbleData.setSelectedBubble(clickedBubble); } else { // If the clicked bubble is the selected bubble (but not the expanded bubble), // that means overflow was previously expanded. Set the selected bubble // internally without going through BubbleData (which would ignore it since it's // already selected). setSelectedBubble(clickedBubble); } } else { // Otherwise, we either tapped the stack (which means we're collapsed // and should expand) or the currently selected bubble (we're expanded // and should collapse). if (!maybeShowStackUserEducation()) { mBubbleData.setExpanded(!mBubbleData.isExpanded()); } } } }; /** * Touch listener set on each bubble view. This enables dragging and dismissing the stack (when * collapsed), or individual bubbles (when expanded). */ private RelativeTouchListener mBubbleTouchListener = new RelativeTouchListener() { @Override public boolean onDown(@NonNull View v, @NonNull MotionEvent ev) { // If we're expanding or collapsing, consume but ignore all touch events. if (mIsExpansionAnimating) { return true; } // If the manage menu is visible, just hide it. if (mShowingManage) { showManageMenu(false /* show */); } if (mBubbleData.isExpanded()) { maybeShowManageEducation(false /* show */); // If we're expanded, tell the animation controller to prepare to drag this bubble, // dispatching to the individual bubble magnet listener. mExpandedAnimationController.prepareForBubbleDrag( v /* bubble */, mMagneticTarget, mIndividualBubbleMagnetListener); hideCurrentInputMethod(); // Save the magnetized individual bubble so we can dispatch touch events to it. mMagnetizedObject = mExpandedAnimationController.getMagnetizedBubbleDraggingOut(); } else { // If we're collapsed, prepare to drag the stack. Cancel active animations, set the // animation controller, and hide the flyout. mStackAnimationController.cancelStackPositionAnimations(); mBubbleContainer.setActiveController(mStackAnimationController); hideFlyoutImmediate(); // Also, save the magnetized stack so we can dispatch touch events to it. mMagnetizedObject = mStackAnimationController.getMagnetizedStack(mMagneticTarget); mMagnetizedObject.setMagnetListener(mStackMagnetListener); mIsDraggingStack = true; // Cancel animations to make the stack temporarily invisible, since we're now // dragging it. updateTemporarilyInvisibleAnimation(false /* hideImmediately */); } passEventToMagnetizedObject(ev); // Bubbles are always interested in all touch events! return true; } @Override public void onMove(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX, float viewInitialY, float dx, float dy) { // If we're expanding or collapsing, ignore all touch events. if (mIsExpansionAnimating) { return; } // Show the dismiss target, if we haven't already. springInDismissTargetMaybe(); // First, see if the magnetized object consumes the event - if so, we shouldn't move the // bubble since it's stuck to the target. if (!passEventToMagnetizedObject(ev)) { if (mBubbleData.isExpanded()) { mExpandedAnimationController.dragBubbleOut( v, viewInitialX + dx, viewInitialY + dy); } else { hideStackUserEducation(false /* fromExpansion */); mStackAnimationController.moveStackFromTouch( viewInitialX + dx, viewInitialY + dy); } } } @Override public void onUp(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX, float viewInitialY, float dx, float dy, float velX, float velY) { // If we're expanding or collapsing, ignore all touch events. if (mIsExpansionAnimating) { return; } // First, see if the magnetized object consumes the event - if so, the bubble was // released in the target or flung out of it, and we should ignore the event. if (!passEventToMagnetizedObject(ev)) { if (mBubbleData.isExpanded()) { mExpandedAnimationController.snapBubbleBack(v, velX, velY); } else { // Fling the stack to the edge, and save whether or not it's going to end up on // the left side of the screen. mStackOnLeftOrWillBe = mStackAnimationController.flingStackThenSpringToEdge( viewInitialX + dx, velX, velY) <= 0; updateBubbleZOrdersAndDotPosition(true /* animate */); logBubbleEvent(null /* no bubble associated with bubble stack move */, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__STACK_MOVED); } hideDismissTarget(); } mIsDraggingStack = false; // Hide the stack after a delay, if needed. updateTemporarilyInvisibleAnimation(false /* hideImmediately */); } }; /** Click listener set on the flyout, which expands the stack when the flyout is tapped. */ private OnClickListener mFlyoutClickListener = new OnClickListener() { @Override public void onClick(View view) { if (maybeShowStackUserEducation()) { // If we're showing user education, don't open the bubble show the education first mBubbleToExpandAfterFlyoutCollapse = null; } else { mBubbleToExpandAfterFlyoutCollapse = mBubbleData.getSelectedBubble(); } mFlyout.removeCallbacks(mHideFlyout); mHideFlyout.run(); } }; /** Touch listener for the flyout. This enables the drag-to-dismiss gesture on the flyout. */ private RelativeTouchListener mFlyoutTouchListener = new RelativeTouchListener() { @Override public boolean onDown(@NonNull View v, @NonNull MotionEvent ev) { mFlyout.removeCallbacks(mHideFlyout); return true; } @Override public void onMove(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX, float viewInitialY, float dx, float dy) { setFlyoutStateForDragLength(dx); } @Override public void onUp(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX, float viewInitialY, float dx, float dy, float velX, float velY) { final boolean onLeft = mStackAnimationController.isStackOnLeftSide(); final boolean metRequiredVelocity = onLeft ? velX < -FLYOUT_DISMISS_VELOCITY : velX > FLYOUT_DISMISS_VELOCITY; final boolean metRequiredDeltaX = onLeft ? dx < -mFlyout.getWidth() * FLYOUT_DRAG_PERCENT_DISMISS : dx > mFlyout.getWidth() * FLYOUT_DRAG_PERCENT_DISMISS; final boolean isCancelFling = onLeft ? velX > 0 : velX < 0; final boolean shouldDismiss = metRequiredVelocity || (metRequiredDeltaX && !isCancelFling); mFlyout.removeCallbacks(mHideFlyout); animateFlyoutCollapsed(shouldDismiss, velX); maybeShowStackUserEducation(); } }; private View mDismissTargetCircle; private ViewGroup mDismissTargetContainer; private PhysicsAnimator mDismissTargetAnimator; private PhysicsAnimator.SpringConfig mDismissTargetSpring = new PhysicsAnimator.SpringConfig( SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_LOW_BOUNCY); private int mOrientation = Configuration.ORIENTATION_UNDEFINED; @Nullable private BubbleOverflow mBubbleOverflow; private boolean mShouldShowUserEducation; private boolean mAnimatingEducationAway; private View mUserEducationView; private boolean mShouldShowManageEducation; private BubbleManageEducationView mManageEducationView; private boolean mAnimatingManageEducationAway; private ViewGroup mManageMenu; private ImageView mManageSettingsIcon; private TextView mManageSettingsText; private boolean mShowingManage = false; private PhysicsAnimator.SpringConfig mManageSpringConfig = new PhysicsAnimator.SpringConfig( SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_LOW_BOUNCY); @SuppressLint("ClickableViewAccessibility") public BubbleStackView(Context context, BubbleData data, @Nullable SurfaceSynchronizer synchronizer, FloatingContentCoordinator floatingContentCoordinator, SysUiState sysUiState, Runnable allBubblesAnimatedOutAction, Consumer onImeVisibilityChanged, Runnable hideCurrentInputMethodCallback) { super(context); mBubbleData = data; mInflater = LayoutInflater.from(context); mSysUiState = sysUiState; Resources res = getResources(); mMaxBubbles = res.getInteger(R.integer.bubbles_max_rendered); mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size); mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation); mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top); mBubbleTouchPadding = res.getDimensionPixelSize(R.dimen.bubble_touch_padding); mStatusBarHeight = res.getDimensionPixelSize(com.android.internal.R.dimen.status_bar_height); mImeOffset = res.getDimensionPixelSize(R.dimen.pip_ime_offset); mDisplaySize = new Point(); WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); // We use the real size & subtract screen decorations / window insets ourselves when needed wm.getDefaultDisplay().getRealSize(mDisplaySize); mExpandedViewPadding = res.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding); int elevation = res.getDimensionPixelSize(R.dimen.bubble_elevation); final TypedArray ta = mContext.obtainStyledAttributes( new int[] {android.R.attr.dialogCornerRadius}); mCornerRadius = ta.getDimensionPixelSize(0, 0); ta.recycle(); final Runnable onBubbleAnimatedOut = () -> { if (getBubbleCount() == 0) { allBubblesAnimatedOutAction.run(); } }; mStackAnimationController = new StackAnimationController( floatingContentCoordinator, this::getBubbleCount, onBubbleAnimatedOut); mExpandedAnimationController = new ExpandedAnimationController( mDisplaySize, mExpandedViewPadding, res.getConfiguration().orientation, onBubbleAnimatedOut); mSurfaceSynchronizer = synchronizer != null ? synchronizer : DEFAULT_SURFACE_SYNCHRONIZER; setUpUserEducation(); // Force LTR by default since most of the Bubbles UI is positioned manually by the user, or // is centered. It greatly simplifies translation positioning/animations. Views that will // actually lay out differently in RTL, such as the flyout and expanded view, will set their // layout direction to LOCALE. setLayoutDirection(LAYOUT_DIRECTION_LTR); mBubbleContainer = new PhysicsAnimationLayout(context); mBubbleContainer.setActiveController(mStackAnimationController); mBubbleContainer.setElevation(elevation); mBubbleContainer.setClipChildren(false); addView(mBubbleContainer, new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); mExpandedViewContainer = new FrameLayout(context); mExpandedViewContainer.setElevation(elevation); mExpandedViewContainer.setClipChildren(false); addView(mExpandedViewContainer); mAnimatingOutSurfaceContainer = new FrameLayout(getContext()); mAnimatingOutSurfaceContainer.setLayoutParams( new ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); addView(mAnimatingOutSurfaceContainer); mAnimatingOutSurfaceView = new SurfaceView(getContext()); mAnimatingOutSurfaceView.setUseAlpha(); mAnimatingOutSurfaceView.setZOrderOnTop(true); mAnimatingOutSurfaceView.setCornerRadius(mCornerRadius); mAnimatingOutSurfaceView.setLayoutParams(new ViewGroup.LayoutParams(0, 0)); mAnimatingOutSurfaceContainer.addView(mAnimatingOutSurfaceView); mAnimatingOutSurfaceContainer.setPadding( mExpandedViewPadding, mExpandedViewPadding, mExpandedViewPadding, mExpandedViewPadding); setUpManageMenu(); setUpFlyout(); mFlyoutTransitionSpring.setSpring(new SpringForce() .setStiffness(SpringForce.STIFFNESS_LOW) .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)); mFlyoutTransitionSpring.addEndListener(mAfterFlyoutTransitionSpring); final int targetSize = res.getDimensionPixelSize(R.dimen.dismiss_circle_size); mDismissTargetCircle = new DismissCircleView(context); final FrameLayout.LayoutParams newParams = new FrameLayout.LayoutParams(targetSize, targetSize); newParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; mDismissTargetCircle.setLayoutParams(newParams); mDismissTargetAnimator = PhysicsAnimator.getInstance(mDismissTargetCircle); mDismissTargetContainer = new FrameLayout(context); mDismissTargetContainer.setLayoutParams(new FrameLayout.LayoutParams( MATCH_PARENT, getResources().getDimensionPixelSize(R.dimen.floating_dismiss_gradient_height), Gravity.BOTTOM)); final int bottomMargin = getResources().getDimensionPixelSize(R.dimen.floating_dismiss_bottom_margin); mDismissTargetContainer.setPadding(0, 0, 0, bottomMargin); mDismissTargetContainer.setClipToPadding(false); mDismissTargetContainer.setClipChildren(false); mDismissTargetContainer.addView(mDismissTargetCircle); mDismissTargetContainer.setVisibility(View.INVISIBLE); mDismissTargetContainer.setBackgroundResource( R.drawable.floating_dismiss_gradient_transition); addView(mDismissTargetContainer); // Start translated down so the target springs up. mDismissTargetCircle.setTranslationY( getResources().getDimensionPixelSize(R.dimen.floating_dismiss_gradient_height)); final ContentResolver contentResolver = getContext().getContentResolver(); final int dismissRadius = Settings.Secure.getInt( contentResolver, "bubble_dismiss_radius", mBubbleSize * 2 /* default */); // Save the MagneticTarget instance for the newly set up view - we'll add this to the // MagnetizedObjects. mMagneticTarget = new MagnetizedObject.MagneticTarget(mDismissTargetCircle, dismissRadius); setClipChildren(false); setFocusable(true); mBubbleContainer.bringToFront(); setUpOverflow(); mOnImeVisibilityChanged = onImeVisibilityChanged; mHideCurrentInputMethodCallback = hideCurrentInputMethodCallback; setOnApplyWindowInsetsListener((View view, WindowInsets insets) -> { onImeVisibilityChanged.accept(insets.getInsets(WindowInsets.Type.ime()).bottom > 0); if (!mIsExpanded || mIsExpansionAnimating) { return view.onApplyWindowInsets(insets); } mExpandedAnimationController.updateYPosition( // Update the insets after we're done translating otherwise position // calculation for them won't be correct. () -> { if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { mExpandedBubble.getExpandedView().updateInsets(insets); } }); return view.onApplyWindowInsets(insets); }); mOrientationChangedListener = (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { mExpandedAnimationController.updateResources(mOrientation, mDisplaySize); mStackAnimationController.updateResources(mOrientation); mBubbleOverflow.updateDimensions(); // Need to update the padding around the view WindowInsets insets = getRootWindowInsets(); int leftPadding = mExpandedViewPadding; int rightPadding = mExpandedViewPadding; if (insets != null) { // Can't have the expanded view overlaying notches int cutoutLeft = 0; int cutoutRight = 0; DisplayCutout cutout = insets.getDisplayCutout(); if (cutout != null) { cutoutLeft = cutout.getSafeInsetLeft(); cutoutRight = cutout.getSafeInsetRight(); } // Or overlaying nav or status bar leftPadding += Math.max(cutoutLeft, insets.getStableInsetLeft()); rightPadding += Math.max(cutoutRight, insets.getStableInsetRight()); } mExpandedViewContainer.setPadding(leftPadding, mExpandedViewPadding, rightPadding, mExpandedViewPadding); if (mIsExpanded) { // Re-draw bubble row and pointer for new orientation. beforeExpandedViewAnimation(); updateOverflowVisibility(); updatePointerPosition(); mExpandedAnimationController.expandFromStack(() -> { afterExpandedViewAnimation(); } /* after */); mExpandedViewContainer.setTranslationX(0); mExpandedViewContainer.setTranslationY(getExpandedViewY()); mExpandedViewContainer.setAlpha(1f); } if (mVerticalPosPercentBeforeRotation >= 0) { mStackAnimationController.moveStackToSimilarPositionAfterRotation( mWasOnLeftBeforeRotation, mVerticalPosPercentBeforeRotation); } removeOnLayoutChangeListener(mOrientationChangedListener); }; // This must be a separate OnDrawListener since it should be called for every draw. getViewTreeObserver().addOnDrawListener(mSystemGestureExcludeUpdater); final ColorMatrix animatedMatrix = new ColorMatrix(); final ColorMatrix darkenMatrix = new ColorMatrix(); mDesaturateAndDarkenAnimator = ValueAnimator.ofFloat(1f, 0f); mDesaturateAndDarkenAnimator.addUpdateListener(animation -> { final float animatedValue = (float) animation.getAnimatedValue(); animatedMatrix.setSaturation(animatedValue); final float animatedDarkenValue = (1f - animatedValue) * DARKEN_PERCENT; darkenMatrix.setScale( 1f - animatedDarkenValue /* red */, 1f - animatedDarkenValue /* green */, 1f - animatedDarkenValue /* blue */, 1f /* alpha */); // Concat the matrices so that the animatedMatrix both desaturates and darkens. animatedMatrix.postConcat(darkenMatrix); // Update the paint and apply it to the bubble container. mDesaturateAndDarkenPaint.setColorFilter(new ColorMatrixColorFilter(animatedMatrix)); if (mDesaturateAndDarkenTargetView != null) { mDesaturateAndDarkenTargetView.setLayerPaint(mDesaturateAndDarkenPaint); } }); // If the stack itself is touched, it means none of its touchable views (bubbles, flyouts, // ActivityViews, etc.) were touched. Collapse the stack if it's expanded. setOnTouchListener((view, ev) -> { if (ev.getAction() == MotionEvent.ACTION_DOWN) { if (mShowingManage) { showManageMenu(false /* show */); } else if (mBubbleData.isExpanded()) { mBubbleData.setExpanded(false); } } return true; }); animate() .setInterpolator(Interpolators.PANEL_CLOSE_ACCELERATED) .setDuration(CollapsedStatusBarFragment.FADE_IN_DURATION); } /** * Sets whether or not the stack should become temporarily invisible by moving off the side of * the screen. * * If a flyout comes in while it's invisible, it will animate back in while the flyout is * showing but disappear again when the flyout is gone. */ public void setTemporarilyInvisible(boolean invisible) { mTemporarilyInvisible = invisible; // If we are animating out, hide immediately if possible so we animate out with the status // bar. updateTemporarilyInvisibleAnimation(invisible /* hideImmediately */); } /** * Animates the stack to be temporarily invisible, if needed. * * If we're currently dragging the stack, or a flyout is visible, the stack will remain visible. * regardless of the value of {@link #mTemporarilyInvisible}. This method is called on ACTION_UP * as well as whenever a flyout hides, so we will animate invisible at that point if needed. */ private void updateTemporarilyInvisibleAnimation(boolean hideImmediately) { removeCallbacks(mAnimateTemporarilyInvisibleImmediate); if (mIsDraggingStack) { // If we're dragging the stack, don't animate it invisible. return; } final boolean shouldHide = mTemporarilyInvisible && mFlyout.getVisibility() != View.VISIBLE; postDelayed(mAnimateTemporarilyInvisibleImmediate, shouldHide && !hideImmediately ? ANIMATE_TEMPORARILY_INVISIBLE_DELAY : 0); } private final Runnable mAnimateTemporarilyInvisibleImmediate = () -> { if (mTemporarilyInvisible && mFlyout.getVisibility() != View.VISIBLE) { if (mStackAnimationController.isStackOnLeftSide()) { animate().translationX(-mBubbleSize).start(); } else { animate().translationX(mBubbleSize).start(); } } else { animate().translationX(0).start(); } }; private void setUpManageMenu() { if (mManageMenu != null) { removeView(mManageMenu); } mManageMenu = (ViewGroup) LayoutInflater.from(getContext()).inflate( R.layout.bubble_manage_menu, this, false); mManageMenu.setVisibility(View.INVISIBLE); PhysicsAnimator.getInstance(mManageMenu).setDefaultSpringConfig(mManageSpringConfig); mManageMenu.setOutlineProvider(new ViewOutlineProvider() { @Override public void getOutline(View view, Outline outline) { outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCornerRadius); } }); mManageMenu.setClipToOutline(true); mManageMenu.findViewById(R.id.bubble_manage_menu_dismiss_container).setOnClickListener( view -> { showManageMenu(false /* show */); dismissBubbleIfExists(mBubbleData.getSelectedBubble()); }); mManageMenu.findViewById(R.id.bubble_manage_menu_dont_bubble_container).setOnClickListener( view -> { showManageMenu(false /* show */); mUnbubbleConversationCallback.accept(mBubbleData.getSelectedBubble().getKey()); }); mManageMenu.findViewById(R.id.bubble_manage_menu_settings_container).setOnClickListener( view -> { showManageMenu(false /* show */); final Bubble bubble = mBubbleData.getSelectedBubble(); if (bubble != null && mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) { final Intent intent = bubble.getSettingsIntent(mContext); collapseStack(() -> { mContext.startActivityAsUser(intent, bubble.getUser()); logBubbleEvent(bubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__HEADER_GO_TO_SETTINGS); }); } }); mManageSettingsIcon = mManageMenu.findViewById(R.id.bubble_manage_menu_settings_icon); mManageSettingsText = mManageMenu.findViewById(R.id.bubble_manage_menu_settings_name); // The menu itself should respect locale direction so the icons are on the correct side. mManageMenu.setLayoutDirection(LAYOUT_DIRECTION_LOCALE); addView(mManageMenu); } private void setUpUserEducation() { if (mUserEducationView != null) { removeView(mUserEducationView); } mShouldShowUserEducation = shouldShowBubblesEducation(); if (DEBUG_USER_EDUCATION) { Log.d(TAG, "shouldShowUserEducation: " + mShouldShowUserEducation); } if (mShouldShowUserEducation) { mUserEducationView = mInflater.inflate(R.layout.bubble_stack_user_education, this, false /* attachToRoot */); mUserEducationView.setVisibility(GONE); final TypedArray ta = mContext.obtainStyledAttributes( new int[] {android.R.attr.colorAccent, android.R.attr.textColorPrimaryInverse}); final int bgColor = ta.getColor(0, Color.BLACK); int textColor = ta.getColor(1, Color.WHITE); ta.recycle(); textColor = ContrastColorUtil.ensureTextContrast(textColor, bgColor, true); TextView title = mUserEducationView.findViewById(R.id.user_education_title); TextView description = mUserEducationView.findViewById(R.id.user_education_description); title.setTextColor(textColor); description.setTextColor(textColor); updateUserEducationForLayoutDirection(); addView(mUserEducationView); } if (mManageEducationView != null) { removeView(mManageEducationView); } mShouldShowManageEducation = shouldShowManageEducation(); if (DEBUG_USER_EDUCATION) { Log.d(TAG, "shouldShowManageEducation: " + mShouldShowManageEducation); } if (mShouldShowManageEducation) { mManageEducationView = (BubbleManageEducationView) mInflater.inflate(R.layout.bubbles_manage_button_education, this, false /* attachToRoot */); mManageEducationView.setVisibility(GONE); mManageEducationView.setElevation(mBubbleElevation); mManageEducationView.setLayoutDirection(View.LAYOUT_DIRECTION_LOCALE); addView(mManageEducationView); } } @SuppressLint("ClickableViewAccessibility") private void setUpFlyout() { if (mFlyout != null) { removeView(mFlyout); } mFlyout = new BubbleFlyoutView(getContext()); mFlyout.setVisibility(GONE); mFlyout.animate() .setDuration(FLYOUT_ALPHA_ANIMATION_DURATION) .setInterpolator(new AccelerateDecelerateInterpolator()); mFlyout.setOnClickListener(mFlyoutClickListener); mFlyout.setOnTouchListener(mFlyoutTouchListener); addView(mFlyout, new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); } private void setUpOverflow() { int overflowBtnIndex = 0; if (mBubbleOverflow == null) { mBubbleOverflow = new BubbleOverflow(getContext()); mBubbleOverflow.setUpOverflow(mBubbleContainer, this); } else { mBubbleContainer.removeView(mBubbleOverflow.getIconView()); mBubbleOverflow.setUpOverflow(mBubbleContainer, this); overflowBtnIndex = mBubbleContainer.getChildCount(); } mBubbleContainer.addView(mBubbleOverflow.getIconView(), overflowBtnIndex, new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); mBubbleOverflow.getIconView().setOnClickListener(v -> { setSelectedBubble(mBubbleOverflow); showManageMenu(false); }); updateOverflowVisibility(); } /** * Handle theme changes. */ public void onThemeChanged() { setUpFlyout(); setUpOverflow(); setUpUserEducation(); setUpManageMenu(); updateExpandedViewTheme(); } /** Respond to the phone being rotated by repositioning the stack and hiding any flyouts. */ public void onOrientationChanged(int orientation) { mOrientation = orientation; // Display size is based on the rotation device was in when requested, we should update it // We use the real size & subtract screen decorations / window insets ourselves when needed WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE); wm.getDefaultDisplay().getRealSize(mDisplaySize); // Some resources change depending on orientation Resources res = getContext().getResources(); mStatusBarHeight = res.getDimensionPixelSize( com.android.internal.R.dimen.status_bar_height); mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top); final RectF allowablePos = mStackAnimationController.getAllowableStackPositionRegion(); mWasOnLeftBeforeRotation = mStackAnimationController.isStackOnLeftSide(); mVerticalPosPercentBeforeRotation = (mStackAnimationController.getStackPosition().y - allowablePos.top) / (allowablePos.bottom - allowablePos.top); mVerticalPosPercentBeforeRotation = Math.max(0f, Math.min(1f, mVerticalPosPercentBeforeRotation)); addOnLayoutChangeListener(mOrientationChangedListener); hideFlyoutImmediate(); mManageMenu.setVisibility(View.INVISIBLE); mShowingManage = false; } /** Tells the views with locale-dependent layout direction to resolve the new direction. */ public void onLayoutDirectionChanged(int direction) { mManageMenu.setLayoutDirection(direction); mFlyout.setLayoutDirection(direction); if (mUserEducationView != null) { mUserEducationView.setLayoutDirection(direction); updateUserEducationForLayoutDirection(); } if (mManageEducationView != null) { mManageEducationView.setLayoutDirection(direction); } updateExpandedViewDirection(direction); } /** Respond to the display size change by recalculating view size and location. */ public void onDisplaySizeChanged() { setUpOverflow(); WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE); wm.getDefaultDisplay().getRealSize(mDisplaySize); Resources res = getContext().getResources(); mStatusBarHeight = res.getDimensionPixelSize( com.android.internal.R.dimen.status_bar_height); mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top); mBubbleSize = getResources().getDimensionPixelSize(R.dimen.individual_bubble_size); for (Bubble b : mBubbleData.getBubbles()) { if (b.getIconView() == null) { Log.d(TAG, "Display size changed. Icon null: " + b); continue; } b.getIconView().setLayoutParams(new LayoutParams(mBubbleSize, mBubbleSize)); } mExpandedAnimationController.updateResources(mOrientation, mDisplaySize); mStackAnimationController.updateResources(mOrientation); final int targetSize = res.getDimensionPixelSize(R.dimen.dismiss_circle_size); mDismissTargetCircle.getLayoutParams().width = targetSize; mDismissTargetCircle.getLayoutParams().height = targetSize; mDismissTargetCircle.requestLayout(); mMagneticTarget.setMagneticFieldRadiusPx(mBubbleSize * 2); } @Override public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) { inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); mTempRect.setEmpty(); getTouchableRegion(mTempRect); inoutInfo.touchableRegion.set(mTempRect); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); getViewTreeObserver().addOnComputeInternalInsetsListener(this); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); getViewTreeObserver().removeOnPreDrawListener(mViewUpdater); getViewTreeObserver().removeOnComputeInternalInsetsListener(this); if (mBubbleOverflow != null && mBubbleOverflow.getExpandedView() != null) { mBubbleOverflow.getExpandedView().cleanUpExpandedState(); } } @Override public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfoInternal(info); setupLocalMenu(info); } void updateExpandedViewTheme() { final List bubbles = mBubbleData.getBubbles(); if (bubbles.isEmpty()) { return; } bubbles.forEach(bubble -> { if (bubble.getExpandedView() != null) { bubble.getExpandedView().applyThemeAttrs(); } }); } void updateExpandedViewDirection(int direction) { final List bubbles = mBubbleData.getBubbles(); if (bubbles.isEmpty()) { return; } bubbles.forEach(bubble -> { if (bubble.getExpandedView() != null) { bubble.getExpandedView().setLayoutDirection(direction); } }); } void setupLocalMenu(AccessibilityNodeInfo info) { Resources res = mContext.getResources(); // Custom local actions. AccessibilityAction moveTopLeft = new AccessibilityAction(R.id.action_move_top_left, res.getString(R.string.bubble_accessibility_action_move_top_left)); info.addAction(moveTopLeft); AccessibilityAction moveTopRight = new AccessibilityAction(R.id.action_move_top_right, res.getString(R.string.bubble_accessibility_action_move_top_right)); info.addAction(moveTopRight); AccessibilityAction moveBottomLeft = new AccessibilityAction(R.id.action_move_bottom_left, res.getString(R.string.bubble_accessibility_action_move_bottom_left)); info.addAction(moveBottomLeft); AccessibilityAction moveBottomRight = new AccessibilityAction(R.id.action_move_bottom_right, res.getString(R.string.bubble_accessibility_action_move_bottom_right)); info.addAction(moveBottomRight); // Default actions. info.addAction(AccessibilityAction.ACTION_DISMISS); if (mIsExpanded) { info.addAction(AccessibilityAction.ACTION_COLLAPSE); } else { info.addAction(AccessibilityAction.ACTION_EXPAND); } } @Override public boolean performAccessibilityActionInternal(int action, Bundle arguments) { if (super.performAccessibilityActionInternal(action, arguments)) { return true; } final RectF stackBounds = mStackAnimationController.getAllowableStackPositionRegion(); // R constants are not final so we cannot use switch-case here. if (action == AccessibilityNodeInfo.ACTION_DISMISS) { mBubbleData.dismissAll(BubbleController.DISMISS_ACCESSIBILITY_ACTION); announceForAccessibility( getResources().getString(R.string.accessibility_bubble_dismissed)); return true; } else if (action == AccessibilityNodeInfo.ACTION_COLLAPSE) { mBubbleData.setExpanded(false); return true; } else if (action == AccessibilityNodeInfo.ACTION_EXPAND) { mBubbleData.setExpanded(true); return true; } else if (action == R.id.action_move_top_left) { mStackAnimationController.springStackAfterFling(stackBounds.left, stackBounds.top); return true; } else if (action == R.id.action_move_top_right) { mStackAnimationController.springStackAfterFling(stackBounds.right, stackBounds.top); return true; } else if (action == R.id.action_move_bottom_left) { mStackAnimationController.springStackAfterFling(stackBounds.left, stackBounds.bottom); return true; } else if (action == R.id.action_move_bottom_right) { mStackAnimationController.springStackAfterFling(stackBounds.right, stackBounds.bottom); return true; } return false; } /** * Update content description for a11y TalkBack. */ public void updateContentDescription() { if (mBubbleData.getBubbles().isEmpty()) { return; } for (int i = 0; i < mBubbleData.getBubbles().size(); i++) { final Bubble bubble = mBubbleData.getBubbles().get(i); final String appName = bubble.getAppName(); String titleStr = bubble.getTitle(); if (titleStr == null) { titleStr = getResources().getString(R.string.notification_bubble_title); } if (bubble.getIconView() != null) { if (mIsExpanded || i > 0) { bubble.getIconView().setContentDescription(getResources().getString( R.string.bubble_content_description_single, titleStr, appName)); } else { final int moreCount = mBubbleContainer.getChildCount() - 1; bubble.getIconView().setContentDescription(getResources().getString( R.string.bubble_content_description_stack, titleStr, appName, moreCount)); } } } } private void updateSystemGestureExcludeRects() { // Exclude the region occupied by the first BubbleView in the stack Rect excludeZone = mSystemGestureExclusionRects.get(0); if (getBubbleCount() > 0) { View firstBubble = mBubbleContainer.getChildAt(0); excludeZone.set(firstBubble.getLeft(), firstBubble.getTop(), firstBubble.getRight(), firstBubble.getBottom()); excludeZone.offset((int) (firstBubble.getTranslationX() + 0.5f), (int) (firstBubble.getTranslationY() + 0.5f)); mBubbleContainer.setSystemGestureExclusionRects(mSystemGestureExclusionRects); } else { excludeZone.setEmpty(); mBubbleContainer.setSystemGestureExclusionRects(Collections.emptyList()); } } /** * Sets the listener to notify when the bubble stack is expanded. */ public void setExpandListener(BubbleController.BubbleExpandListener listener) { mExpandListener = listener; } /** Sets the function to call to un-bubble the given conversation. */ public void setUnbubbleConversationCallback( Consumer unbubbleConversationCallback) { mUnbubbleConversationCallback = unbubbleConversationCallback; } /** * Whether the stack of bubbles is expanded or not. */ public boolean isExpanded() { return mIsExpanded; } /** * Whether the stack of bubbles is animating to or from expansion. */ public boolean isExpansionAnimating() { return mIsExpansionAnimating; } /** * The {@link BadgedImageView} that is expanded, null if one does not exist. */ View getExpandedBubbleView() { return mExpandedBubble != null ? mExpandedBubble.getIconView() : null; } /** * The {@link Bubble} that is expanded, null if one does not exist. */ @Nullable BubbleViewProvider getExpandedBubble() { return mExpandedBubble; } // via BubbleData.Listener @SuppressLint("ClickableViewAccessibility") void addBubble(Bubble bubble) { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "addBubble: " + bubble); } if (getBubbleCount() == 0 && mShouldShowUserEducation) { // Override the default stack position if we're showing user education. mStackAnimationController.setStackPosition( mStackAnimationController.getDefaultStartPosition()); } if (getBubbleCount() == 0) { mStackOnLeftOrWillBe = mStackAnimationController.isStackOnLeftSide(); } if (bubble.getIconView() == null) { return; } // Set the dot position to the opposite of the side the stack is resting on, since the stack // resting slightly off-screen would result in the dot also being off-screen. bubble.getIconView().setDotPositionOnLeft( !mStackOnLeftOrWillBe /* onLeft */, false /* animate */); bubble.getIconView().setOnClickListener(mBubbleClickListener); bubble.getIconView().setOnTouchListener(mBubbleTouchListener); mBubbleContainer.addView(bubble.getIconView(), 0, new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); animateInFlyoutForBubble(bubble); requestUpdate(); logBubbleEvent(bubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__POSTED); } // via BubbleData.Listener void removeBubble(Bubble bubble) { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "removeBubble: " + bubble); } // Remove it from the views for (int i = 0; i < getBubbleCount(); i++) { View v = mBubbleContainer.getChildAt(i); if (v instanceof BadgedImageView && ((BadgedImageView) v).getKey().equals(bubble.getKey())) { mBubbleContainer.removeViewAt(i); bubble.cleanupViews(); updatePointerPosition(); logBubbleEvent(bubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__DISMISSED); return; } } Log.d(TAG, "was asked to remove Bubble, but didn't find the view! " + bubble); } private void updateOverflowVisibility() { if (mBubbleOverflow == null) { return; } mBubbleOverflow.setVisible(mIsExpanded ? VISIBLE : GONE); } // via BubbleData.Listener void updateBubble(Bubble bubble) { animateInFlyoutForBubble(bubble); requestUpdate(); logBubbleEvent(bubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__UPDATED); } public void updateBubbleOrder(List bubbles) { for (int i = 0; i < bubbles.size(); i++) { Bubble bubble = bubbles.get(i); mBubbleContainer.reorderView(bubble.getIconView(), i); } updateBubbleZOrdersAndDotPosition(false /* animate */); updatePointerPosition(); } /** * Changes the currently selected bubble. If the stack is already expanded, the newly selected * bubble will be shown immediately. This does not change the expanded state or change the * position of any bubble. */ // via BubbleData.Listener public void setSelectedBubble(@Nullable BubbleViewProvider bubbleToSelect) { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "setSelectedBubble: " + bubbleToSelect); } // Ignore this new bubble only if it is the exact same bubble object. Otherwise, we'll want // to re-render it even if it has the same key (equals() returns true). If the currently // expanded bubble is removed and instantly re-added, we'll get back a new Bubble instance // with the same key (with newly inflated expanded views), and we need to render those new // views. if (mExpandedBubble == bubbleToSelect) { return; } if (bubbleToSelect == null || bubbleToSelect.getKey() != BubbleOverflow.KEY) { mBubbleData.setShowingOverflow(false); } else { mBubbleData.setShowingOverflow(true); } if (mIsExpanded && mIsExpansionAnimating) { // If the bubble selection changed during the expansion animation, the expanding bubble // probably crashed or immediately removed itself (or, we just got unlucky with a new // auto-expanding bubble showing up at just the right time). Cancel the animations so we // can start fresh. cancelAllExpandCollapseSwitchAnimations(); } // If we're expanded, screenshot the currently expanded bubble (before expanding the newly // selected bubble) so we can animate it out. if (mIsExpanded && mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { // Before screenshotting, have the real ActivityView show on top of other surfaces // so that the screenshot doesn't flicker on top of it. mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(true); } try { screenshotAnimatingOutBubbleIntoSurface((success) -> { mAnimatingOutSurfaceContainer.setVisibility( success ? View.VISIBLE : View.INVISIBLE); showNewlySelectedBubble(bubbleToSelect); }); } catch (Exception e) { showNewlySelectedBubble(bubbleToSelect); e.printStackTrace(); } } else { showNewlySelectedBubble(bubbleToSelect); } } private void showNewlySelectedBubble(BubbleViewProvider bubbleToSelect) { final BubbleViewProvider previouslySelected = mExpandedBubble; mExpandedBubble = bubbleToSelect; updatePointerPosition(); if (mIsExpanded) { hideCurrentInputMethod(); // Make the container of the expanded view transparent before removing the expanded view // from it. Otherwise a punch hole created by {@link android.view.SurfaceView} in the // expanded view becomes visible on the screen. See b/126856255 mExpandedViewContainer.setAlpha(0.0f); mSurfaceSynchronizer.syncSurfaceAndRun(() -> { if (previouslySelected != null) { previouslySelected.setContentVisibility(false); } updateExpandedBubble(); requestUpdate(); logBubbleEvent(previouslySelected, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED); logBubbleEvent(bubbleToSelect, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED); notifyExpansionChanged(previouslySelected, false /* expanded */); notifyExpansionChanged(bubbleToSelect, true /* expanded */); }); } } /** * Changes the expanded state of the stack. * * @param shouldExpand whether the bubble stack should appear expanded */ // via BubbleData.Listener public void setExpanded(boolean shouldExpand) { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "setExpanded: " + shouldExpand); } if (!shouldExpand) { // If we're collapsing, release the animating-out surface immediately since we have no // need for it, and this ensures it cannot remain visible as we collapse. releaseAnimatingOutBubbleBuffer(); } if (shouldExpand == mIsExpanded) { return; } hideCurrentInputMethod(); mSysUiState .setFlag(QuickStepContract.SYSUI_STATE_BUBBLES_EXPANDED, shouldExpand) .commitUpdate(mContext.getDisplayId()); if (mIsExpanded) { animateCollapse(); logBubbleEvent(mExpandedBubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED); } else { animateExpansion(); // TODO: move next line to BubbleData logBubbleEvent(mExpandedBubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED); logBubbleEvent(mExpandedBubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__STACK_EXPANDED); } notifyExpansionChanged(mExpandedBubble, mIsExpanded); } /** * If necessary, shows the user education view for the bubble stack. This appears the first * time a user taps on a bubble. * * @return true if user education was shown, false otherwise. */ private boolean maybeShowStackUserEducation() { if (mShouldShowUserEducation && mUserEducationView.getVisibility() != VISIBLE) { mUserEducationView.setAlpha(0); mUserEducationView.setVisibility(VISIBLE); updateUserEducationForLayoutDirection(); // Post so we have height of mUserEducationView mUserEducationView.post(() -> { final int viewHeight = mUserEducationView.getHeight(); PointF stackPosition = mStackAnimationController.getDefaultStartPosition(); final float translationY = stackPosition.y + (mBubbleSize / 2) - (viewHeight / 2); mUserEducationView.setTranslationY(translationY); mUserEducationView.animate() .setDuration(ANIMATE_STACK_USER_EDUCATION_DURATION) .setInterpolator(FAST_OUT_SLOW_IN) .alpha(1); }); Prefs.putBoolean(getContext(), HAS_SEEN_BUBBLES_EDUCATION, true); return true; } return false; } private void updateUserEducationForLayoutDirection() { if (mUserEducationView == null) { return; } LinearLayout textLayout = mUserEducationView.findViewById(R.id.user_education_view); TextView title = mUserEducationView.findViewById(R.id.user_education_title); TextView description = mUserEducationView.findViewById(R.id.user_education_description); boolean isLtr = getResources().getConfiguration().getLayoutDirection() == LAYOUT_DIRECTION_LTR; if (isLtr) { mUserEducationView.setLayoutDirection(LAYOUT_DIRECTION_LTR); textLayout.setBackgroundResource(R.drawable.bubble_stack_user_education_bg); title.setGravity(Gravity.LEFT); description.setGravity(Gravity.LEFT); } else { mUserEducationView.setLayoutDirection(LAYOUT_DIRECTION_RTL); textLayout.setBackgroundResource(R.drawable.bubble_stack_user_education_bg_rtl); title.setGravity(Gravity.RIGHT); description.setGravity(Gravity.RIGHT); } } /** * If necessary, hides the user education view for the bubble stack. * * @param fromExpansion if true this indicates the hide is happening due to the bubble being * expanded, false if due to a touch outside of the bubble stack. */ void hideStackUserEducation(boolean fromExpansion) { if (mShouldShowUserEducation && mUserEducationView.getVisibility() == VISIBLE && !mAnimatingEducationAway) { mAnimatingEducationAway = true; mUserEducationView.animate() .alpha(0) .setDuration(fromExpansion ? ANIMATE_STACK_USER_EDUCATION_DURATION_SHORT : ANIMATE_STACK_USER_EDUCATION_DURATION) .withEndAction(() -> { mAnimatingEducationAway = false; mShouldShowUserEducation = shouldShowBubblesEducation(); mUserEducationView.setVisibility(GONE); }); } } /** * If necessary, toggles the user education view for the manage button. This is shown when the * bubble stack is expanded for the first time. * * @param show whether the user education view should show or not. */ void maybeShowManageEducation(boolean show) { if (mManageEducationView == null) { return; } if (show && mShouldShowManageEducation && mManageEducationView.getVisibility() != VISIBLE && mIsExpanded && mExpandedBubble.getExpandedView() != null) { mManageEducationView.setAlpha(0); mManageEducationView.setVisibility(VISIBLE); mManageEducationView.post(() -> { mExpandedBubble.getExpandedView().getManageButtonBoundsOnScreen(mTempRect); final int viewHeight = mManageEducationView.getManageViewHeight(); final int inset = getResources().getDimensionPixelSize( R.dimen.bubbles_manage_education_top_inset); mManageEducationView.bringToFront(); mManageEducationView.setManageViewPosition(0, mTempRect.top - viewHeight + inset); mManageEducationView.animate() .setDuration(ANIMATE_STACK_USER_EDUCATION_DURATION) .setInterpolator(FAST_OUT_SLOW_IN).alpha(1); mManageEducationView.findViewById(R.id.manage).setOnClickListener(view -> { mExpandedBubble.getExpandedView().findViewById(R.id.settings_button) .performClick(); maybeShowManageEducation(false); }); mManageEducationView.findViewById(R.id.got_it).setOnClickListener(view -> maybeShowManageEducation(false)); mManageEducationView.setOnClickListener(view -> maybeShowManageEducation(false)); }); Prefs.putBoolean(getContext(), HAS_SEEN_BUBBLES_MANAGE_EDUCATION, true); } else if (!show && mManageEducationView.getVisibility() == VISIBLE && !mAnimatingManageEducationAway) { mManageEducationView.animate() .alpha(0) .setDuration(mIsExpansionAnimating ? ANIMATE_STACK_USER_EDUCATION_DURATION_SHORT : ANIMATE_STACK_USER_EDUCATION_DURATION) .withEndAction(() -> { mAnimatingManageEducationAway = false; mShouldShowManageEducation = shouldShowManageEducation(); mManageEducationView.setVisibility(GONE); }); } } /** * Dismiss the stack of bubbles. * * @deprecated */ @Deprecated void stackDismissed(int reason) { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "stackDismissed: reason=" + reason); } mBubbleData.dismissAll(reason); logBubbleEvent(null /* no bubble associated with bubble stack dismiss */, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__STACK_DISMISSED); } /** * @deprecated use {@link #setExpanded(boolean)} and * {@link BubbleData#setSelectedBubble(Bubble)} */ @Deprecated @MainThread void collapseStack(Runnable endRunnable) { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "collapseStack(endRunnable)"); } mBubbleData.setExpanded(false); // TODO - use the runnable at end of animation endRunnable.run(); } void showExpandedViewContents(int displayId) { if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null && mExpandedBubble.getExpandedView().getVirtualDisplayId() == displayId) { mExpandedBubble.setContentVisibility(true); } } /** * Asks the BubbleController to hide the IME from anywhere, whether it's focused on Bubbles or * not. */ void hideCurrentInputMethod() { mHideCurrentInputMethodCallback.run(); } private void beforeExpandedViewAnimation() { mIsExpansionAnimating = true; hideFlyoutImmediate(); updateExpandedBubble(); updateExpandedView(); } private void afterExpandedViewAnimation() { mIsExpansionAnimating = false; updateExpandedView(); requestUpdate(); } private void animateExpansion() { cancelDelayedExpandCollapseSwitchAnimations(); mIsExpanded = true; hideStackUserEducation(true /* fromExpansion */); beforeExpandedViewAnimation(); mBubbleContainer.setActiveController(mExpandedAnimationController); updateOverflowVisibility(); updatePointerPosition(); mExpandedAnimationController.expandFromStack(() -> { afterExpandedViewAnimation(); maybeShowManageEducation(true); } /* after */); mExpandedViewContainer.setTranslationX(0); mExpandedViewContainer.setTranslationY(getExpandedViewY()); mExpandedViewContainer.setAlpha(1f); // X-value of the bubble we're expanding, once it's settled in its row. final float bubbleWillBeAtX = mExpandedAnimationController.getBubbleLeft( mBubbleData.getBubbles().indexOf(mExpandedBubble)); // How far horizontally the bubble will be animating. We'll wait a bit longer for bubbles // that are animating farther, so that the expanded view doesn't move as much. final float horizontalDistanceAnimated = Math.abs(bubbleWillBeAtX - mStackAnimationController.getStackPosition().x); // Wait for the path animation target to reach its end, and add a small amount of extra time // if the bubble is moving a lot horizontally. long startDelay = 0L; // Should not happen since we lay out before expanding, but just in case... if (getWidth() > 0) { startDelay = (long) (ExpandedAnimationController.EXPAND_COLLAPSE_TARGET_ANIM_DURATION + (horizontalDistanceAnimated / getWidth()) * 30); } // Set the pivot point for the scale, so the expanded view animates out from the bubble. mExpandedViewContainerMatrix.setScale( 0f, 0f, bubbleWillBeAtX + mBubbleSize / 2f, getExpandedViewY()); mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix); if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false); } mDelayedAnimationHandler.postDelayed(() -> { PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel(); PhysicsAnimator.getInstance(mExpandedViewContainerMatrix) .spring(AnimatableScaleMatrix.SCALE_X, AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f), mScaleInSpringConfig) .spring(AnimatableScaleMatrix.SCALE_Y, AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f), mScaleInSpringConfig) .addUpdateListener((target, values) -> { if (mExpandedBubble == null || mExpandedBubble.getIconView() == null) { return; } mExpandedViewContainerMatrix.postTranslate( mExpandedBubble.getIconView().getTranslationX() - bubbleWillBeAtX, 0); mExpandedViewContainer.setAnimationMatrix( mExpandedViewContainerMatrix); }) .withEndActions(() -> { if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { mExpandedBubble.getExpandedView() .setContentVisibility(true); mExpandedBubble.getExpandedView() .setSurfaceZOrderedOnTop(false); } }) .start(); }, startDelay); } private void animateCollapse() { cancelDelayedExpandCollapseSwitchAnimations(); // Hide the menu if it's visible. showManageMenu(false); mIsExpanded = false; mIsExpansionAnimating = true; mBubbleContainer.cancelAllAnimations(); // If we were in the middle of swapping, the animating-out surface would have been scaling // to zero - finish it off. PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel(); mAnimatingOutSurfaceContainer.setScaleX(0f); mAnimatingOutSurfaceContainer.setScaleY(0f); // Let the expanded animation controller know that it shouldn't animate child adds/reorders // since we're about to animate collapsed. mExpandedAnimationController.notifyPreparingToCollapse(); final long startDelay = (long) (ExpandedAnimationController.EXPAND_COLLAPSE_TARGET_ANIM_DURATION * 0.6f); mDelayedAnimationHandler.postDelayed(() -> mExpandedAnimationController.collapseBackToStack( mStackAnimationController.getStackPositionAlongNearestHorizontalEdge() /* collapseTo */, () -> mBubbleContainer.setActiveController(mStackAnimationController)), startDelay); // We want to visually collapse into this bubble during the animation. final View expandingFromBubble = mExpandedBubble.getIconView(); // X-value the bubble is animating from (back into the stack). final float expandingFromBubbleAtX = mExpandedAnimationController.getBubbleLeft( mBubbleData.getBubbles().indexOf(mExpandedBubble)); // Set the pivot point. mExpandedViewContainerMatrix.setScale( 1f, 1f, expandingFromBubbleAtX + mBubbleSize / 2f, getExpandedViewY()); PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel(); PhysicsAnimator.getInstance(mExpandedViewContainerMatrix) .spring(AnimatableScaleMatrix.SCALE_X, 0f, mScaleOutSpringConfig) .spring(AnimatableScaleMatrix.SCALE_Y, 0f, mScaleOutSpringConfig) .addUpdateListener((target, values) -> { if (expandingFromBubble != null) { // Follow the bubble as it translates! mExpandedViewContainerMatrix.postTranslate( expandingFromBubble.getTranslationX() - expandingFromBubbleAtX, 0f); } mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix); // Hide early so we don't have a tiny little expanded view still visible at the // end of the scale animation. if (mExpandedViewContainerMatrix.getScaleX() < 0.05f) { mExpandedViewContainer.setVisibility(View.INVISIBLE); } }) .withEndActions(() -> { final BubbleViewProvider previouslySelected = mExpandedBubble; beforeExpandedViewAnimation(); maybeShowManageEducation(false); if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "animateCollapse"); Log.d(TAG, BubbleDebugConfig.formatBubblesString(getBubblesOnScreen(), mExpandedBubble)); } updateOverflowVisibility(); afterExpandedViewAnimation(); if (previouslySelected != null) { previouslySelected.setContentVisibility(false); } }) .start(); } private void animateSwitchBubbles() { // If we're no longer expanded, this is meaningless. if (!mIsExpanded) { return; } mIsBubbleSwitchAnimating = true; // The surface contains a screenshot of the animating out bubble, so we just need to animate // it out (and then release the GraphicBuffer). PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel(); PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer) .spring(DynamicAnimation.SCALE_X, 0f, mScaleOutSpringConfig) .spring(DynamicAnimation.SCALE_Y, 0f, mScaleOutSpringConfig) .spring(DynamicAnimation.TRANSLATION_Y, mAnimatingOutSurfaceContainer.getTranslationY() - mBubbleSize * 2, mTranslateSpringConfig) .withEndActions(this::releaseAnimatingOutBubbleBuffer) .start(); boolean isOverflow = mExpandedBubble != null && mExpandedBubble.getKey().equals(BubbleOverflow.KEY); float expandingFromBubbleDestinationX = mExpandedAnimationController.getBubbleLeft(isOverflow ? getBubbleCount() : mBubbleData.getBubbles().indexOf(mExpandedBubble)); mExpandedViewContainer.setAlpha(1f); mExpandedViewContainer.setVisibility(View.VISIBLE); mExpandedViewContainerMatrix.setScale( 0f, 0f, expandingFromBubbleDestinationX + mBubbleSize / 2f, getExpandedViewY()); mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix); mDelayedAnimationHandler.postDelayed(() -> { if (!mIsExpanded) { mIsBubbleSwitchAnimating = false; return; } PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel(); PhysicsAnimator.getInstance(mExpandedViewContainerMatrix) .spring(AnimatableScaleMatrix.SCALE_X, AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f), mScaleInSpringConfig) .spring(AnimatableScaleMatrix.SCALE_Y, AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f), mScaleInSpringConfig) .addUpdateListener((target, values) -> { mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix); }) .withEndActions(() -> { if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { mExpandedBubble.getExpandedView().setContentVisibility(true); mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false); } mIsBubbleSwitchAnimating = false; }) .start(); }, 25); } /** * Cancels any delayed steps for expand/collapse and bubble switch animations, and resets the is * animating flags for those animations. */ private void cancelDelayedExpandCollapseSwitchAnimations() { mDelayedAnimationHandler.removeCallbacksAndMessages(null); mIsExpansionAnimating = false; mIsBubbleSwitchAnimating = false; } private void cancelAllExpandCollapseSwitchAnimations() { cancelDelayedExpandCollapseSwitchAnimations(); PhysicsAnimator.getInstance(mAnimatingOutSurfaceView).cancel(); PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel(); mExpandedViewContainer.setAnimationMatrix(null); } private void notifyExpansionChanged(BubbleViewProvider bubble, boolean expanded) { if (mExpandListener != null && bubble != null) { mExpandListener.onBubbleExpandChanged(expanded, bubble.getKey()); } } /** Return the BubbleView at the given index from the bubble container. */ public BadgedImageView getBubbleAt(int i) { return getBubbleCount() > i ? (BadgedImageView) mBubbleContainer.getChildAt(i) : null; } /** Moves the bubbles out of the way if they're going to be over the keyboard. */ public void onImeVisibilityChanged(boolean visible, int height) { mStackAnimationController.setImeHeight(visible ? height + mImeOffset : 0); if (!mIsExpanded && getBubbleCount() > 0) { final float stackDestinationY = mStackAnimationController.animateForImeVisibility(visible); // How far the stack is animating due to IME, we'll just animate the flyout by that // much too. final float stackDy = stackDestinationY - mStackAnimationController.getStackPosition().y; // If the flyout is visible, translate it along with the bubble stack. if (mFlyout.getVisibility() == VISIBLE) { PhysicsAnimator.getInstance(mFlyout) .spring(DynamicAnimation.TRANSLATION_Y, mFlyout.getTranslationY() + stackDy, FLYOUT_IME_ANIMATION_SPRING_CONFIG) .start(); } } } /** * This method is called by {@link android.app.ActivityView} because the BubbleStackView has a * higher Z-index than the ActivityView (so that dragged-out bubbles are visible over the AV). * ActivityView is asking BubbleStackView to subtract the stack's bounds from the provided * touchable region, so that the ActivityView doesn't consume events meant for the stack. Due to * the special nature of ActivityView, it does not respect the standard * {@link #dispatchTouchEvent} and {@link #onInterceptTouchEvent} methods typically used for * this purpose. * * BubbleStackView is MATCH_PARENT, so that bubbles can be positioned via their translation * properties for performance reasons. This means that the default implementation of this method * subtracts the entirety of the screen from the ActivityView's touchable region, resulting in * it not receiving any touch events. This was previously addressed by returning false in the * stack's {@link View#canReceivePointerEvents()} method, but this precluded the use of any * touch handlers in the stack or its child views. * * To support touch handlers, we're overriding this method to leave the ActivityView's touchable * region alone. The only touchable part of the stack that can ever overlap the AV is a * dragged-out bubble that is animating back into the row of bubbles. It's not worth continually * updating the touchable region to allow users to grab a bubble while it completes its ~50ms * animation back to the bubble row. * * NOTE: Any future additions to the stack that obscure the ActivityView region will need their * bounds subtracted here in order to receive touch events. */ @Override public void subtractObscuredTouchableRegion(Region touchableRegion, View view) { // If the notification shade is expanded, or the manage menu is open, or we are showing // manage bubbles user education, we shouldn't let the ActivityView steal any touch events // from any location. if (!mIsExpanded || mShowingManage || (mManageEducationView != null && mManageEducationView.getVisibility() == VISIBLE)) { touchableRegion.setEmpty(); } } /** * If you're here because you're not receiving touch events on a view that is a descendant of * BubbleStackView, and you think BSV is intercepting them - it's not! You need to subtract the * bounds of the view in question in {@link #subtractObscuredTouchableRegion}. The ActivityView * consumes all touch events within its bounds, even for views like the BubbleStackView that are * above it. It ignores typical view touch handling methods like this one and * dispatchTouchEvent. */ @Override public boolean onInterceptTouchEvent(MotionEvent ev) { return super.onInterceptTouchEvent(ev); } @Override public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getAction() != MotionEvent.ACTION_DOWN && ev.getActionIndex() != mPointerIndexDown) { // Ignore touches from additional pointer indices. return false; } if (ev.getAction() == MotionEvent.ACTION_DOWN) { mPointerIndexDown = ev.getActionIndex(); } else if (ev.getAction() == MotionEvent.ACTION_UP || ev.getAction() == MotionEvent.ACTION_CANCEL) { mPointerIndexDown = -1; } boolean dispatched = super.dispatchTouchEvent(ev); // If a new bubble arrives while the collapsed stack is being dragged, it will be positioned // at the front of the stack (under the touch position). Subsequent ACTION_MOVE events will // then be passed to the new bubble, which will not consume them since it hasn't received an // ACTION_DOWN yet. Work around this by passing MotionEvents directly to the touch handler // until the current gesture ends with an ACTION_UP event. if (!dispatched && !mIsExpanded && mIsGestureInProgress) { dispatched = mBubbleTouchListener.onTouch(this /* view */, ev); } mIsGestureInProgress = ev.getAction() != MotionEvent.ACTION_UP && ev.getAction() != MotionEvent.ACTION_CANCEL; return dispatched; } void setFlyoutStateForDragLength(float deltaX) { // This shouldn't happen, but if it does, just wait until the flyout lays out. This method // is continually called. if (mFlyout.getWidth() <= 0) { return; } final boolean onLeft = mStackAnimationController.isStackOnLeftSide(); mFlyoutDragDeltaX = deltaX; final float collapsePercent = onLeft ? -deltaX / mFlyout.getWidth() : deltaX / mFlyout.getWidth(); mFlyout.setCollapsePercent(Math.min(1f, Math.max(0f, collapsePercent))); // Calculate how to translate the flyout if it has been dragged too far in either direction. float overscrollTranslation = 0f; if (collapsePercent < 0f || collapsePercent > 1f) { // Whether we are more than 100% transitioned to the dot. final boolean overscrollingPastDot = collapsePercent > 1f; // Whether we are overscrolling physically to the left - this can either be pulling the // flyout away from the stack (if the stack is on the right) or pushing it to the left // after it has already become the dot. final boolean overscrollingLeft = (onLeft && collapsePercent > 1f) || (!onLeft && collapsePercent < 0f); overscrollTranslation = (overscrollingPastDot ? collapsePercent - 1f : collapsePercent * -1) * (overscrollingLeft ? -1 : 1) * (mFlyout.getWidth() / (FLYOUT_OVERSCROLL_ATTENUATION_FACTOR // Attenuate the smaller dot less than the larger flyout. / (overscrollingPastDot ? 2 : 1))); } mFlyout.setTranslationX(mFlyout.getRestingTranslationX() + overscrollTranslation); } /** Passes the MotionEvent to the magnetized object and returns true if it was consumed. */ private boolean passEventToMagnetizedObject(MotionEvent event) { return mMagnetizedObject != null && mMagnetizedObject.maybeConsumeMotionEvent(event); } /** * Dismisses the magnetized object - either an individual bubble, if we're expanded, or the * stack, if we're collapsed. */ private void dismissMagnetizedObject() { if (mIsExpanded) { final View draggedOutBubbleView = (View) mMagnetizedObject.getUnderlyingObject(); dismissBubbleIfExists(mBubbleData.getBubbleWithView(draggedOutBubbleView)); } else { mBubbleData.dismissAll(BubbleController.DISMISS_USER_GESTURE); } } private void dismissBubbleIfExists(@Nullable Bubble bubble) { if (bubble != null && mBubbleData.hasBubbleInStackWithKey(bubble.getKey())) { mBubbleData.dismissBubbleWithKey( bubble.getKey(), BubbleController.DISMISS_USER_GESTURE); } } /** Prepares and starts the desaturate/darken animation on the bubble stack. */ private void animateDesaturateAndDarken(View targetView, boolean desaturateAndDarken) { mDesaturateAndDarkenTargetView = targetView; if (mDesaturateAndDarkenTargetView == null) { return; } if (desaturateAndDarken) { // Use the animated paint for the bubbles. mDesaturateAndDarkenTargetView.setLayerType( View.LAYER_TYPE_HARDWARE, mDesaturateAndDarkenPaint); mDesaturateAndDarkenAnimator.removeAllListeners(); mDesaturateAndDarkenAnimator.start(); } else { mDesaturateAndDarkenAnimator.removeAllListeners(); mDesaturateAndDarkenAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); // Stop using the animated paint. resetDesaturationAndDarken(); } }); mDesaturateAndDarkenAnimator.reverse(); } } private void resetDesaturationAndDarken() { mDesaturateAndDarkenAnimator.removeAllListeners(); mDesaturateAndDarkenAnimator.cancel(); if (mDesaturateAndDarkenTargetView != null) { mDesaturateAndDarkenTargetView.setLayerType(View.LAYER_TYPE_NONE, null); mDesaturateAndDarkenTargetView = null; } } /** Animates in the dismiss target. */ private void springInDismissTargetMaybe() { if (mShowingDismiss) { return; } mShowingDismiss = true; mDismissTargetContainer.bringToFront(); mDismissTargetContainer.setZ(Short.MAX_VALUE - 1); mDismissTargetContainer.setVisibility(VISIBLE); ((TransitionDrawable) mDismissTargetContainer.getBackground()).startTransition( DISMISS_TRANSITION_DURATION_MS); mDismissTargetAnimator.cancel(); mDismissTargetAnimator .spring(DynamicAnimation.TRANSLATION_Y, 0f, mDismissTargetSpring) .start(); } /** * Animates the dismiss target out, as well as the circle that encircles the bubbles, if they * were dragged into the target and encircled. */ private void hideDismissTarget() { if (!mShowingDismiss) { return; } mShowingDismiss = false; ((TransitionDrawable) mDismissTargetContainer.getBackground()).reverseTransition( DISMISS_TRANSITION_DURATION_MS); mDismissTargetAnimator .spring(DynamicAnimation.TRANSLATION_Y, mDismissTargetContainer.getHeight(), mDismissTargetSpring) .withEndActions(() -> mDismissTargetContainer.setVisibility(View.INVISIBLE)) .start(); } /** Animates the flyout collapsed (to dot), or the reverse, starting with the given velocity. */ private void animateFlyoutCollapsed(boolean collapsed, float velX) { final boolean onLeft = mStackAnimationController.isStackOnLeftSide(); // If the flyout was tapped, we want a higher stiffness for the collapse animation so it's // faster. mFlyoutTransitionSpring.getSpring().setStiffness( (mBubbleToExpandAfterFlyoutCollapse != null) ? SpringForce.STIFFNESS_MEDIUM : SpringForce.STIFFNESS_LOW); mFlyoutTransitionSpring .setStartValue(mFlyoutDragDeltaX) .setStartVelocity(velX) .animateToFinalPosition(collapsed ? (onLeft ? -mFlyout.getWidth() : mFlyout.getWidth()) : 0f); } /** * Calculates the y position of the expanded view when it is expanded. */ float getExpandedViewY() { return getStatusBarHeight() + mBubbleSize + mBubblePaddingTop; } /** * Animates in the flyout for the given bubble, if available, and then hides it after some time. */ @VisibleForTesting void animateInFlyoutForBubble(Bubble bubble) { Bubble.FlyoutMessage flyoutMessage = bubble.getFlyoutMessage(); final BadgedImageView bubbleView = bubble.getIconView(); if (flyoutMessage == null || flyoutMessage.message == null || !bubble.showFlyout() || (mUserEducationView != null && mUserEducationView.getVisibility() == VISIBLE) || isExpanded() || mIsExpansionAnimating || mIsGestureInProgress || mBubbleToExpandAfterFlyoutCollapse != null || bubbleView == null) { if (bubbleView != null) { bubbleView.removeDotSuppressionFlag(BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE); } // Skip the message if none exists, we're expanded or animating expansion, or we're // about to expand a bubble from the previous tapped flyout, or if bubble view is null. return; } mFlyoutDragDeltaX = 0f; clearFlyoutOnHide(); mAfterFlyoutHidden = () -> { // Null it out to ensure it runs once. mAfterFlyoutHidden = null; if (mBubbleToExpandAfterFlyoutCollapse != null) { // User tapped on the flyout and we should expand mBubbleData.setSelectedBubble(mBubbleToExpandAfterFlyoutCollapse); mBubbleData.setExpanded(true); mBubbleToExpandAfterFlyoutCollapse = null; } // Stop suppressing the dot now that the flyout has morphed into the dot. bubbleView.removeDotSuppressionFlag( BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE); mFlyout.setVisibility(INVISIBLE); // Hide the stack after a delay, if needed. updateTemporarilyInvisibleAnimation(false /* hideImmediately */); }; mFlyout.setVisibility(INVISIBLE); // Suppress the dot when we are animating the flyout. bubbleView.addDotSuppressionFlag( BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE); // Start flyout expansion. Post in case layout isn't complete and getWidth returns 0. post(() -> { // An auto-expanding bubble could have been posted during the time it takes to // layout. if (isExpanded()) { return; } final Runnable expandFlyoutAfterDelay = () -> { mAnimateInFlyout = () -> { mFlyout.setVisibility(VISIBLE); updateTemporarilyInvisibleAnimation(false /* hideImmediately */); mFlyoutDragDeltaX = mStackAnimationController.isStackOnLeftSide() ? -mFlyout.getWidth() : mFlyout.getWidth(); animateFlyoutCollapsed(false /* collapsed */, 0 /* velX */); mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER); }; mFlyout.postDelayed(mAnimateInFlyout, 200); }; if (bubble.getIconView() == null) { return; } mFlyout.setupFlyoutStartingAsDot(flyoutMessage, mStackAnimationController.getStackPosition(), getWidth(), mStackAnimationController.isStackOnLeftSide(), bubble.getIconView().getDotColor() /* dotColor */, expandFlyoutAfterDelay /* onLayoutComplete */, mAfterFlyoutHidden, bubble.getIconView().getDotCenter(), !bubble.showDot()); mFlyout.bringToFront(); }); mFlyout.removeCallbacks(mHideFlyout); mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER); logBubbleEvent(bubble, SysUiStatsLog.BUBBLE_UICHANGED__ACTION__FLYOUT); } /** Hide the flyout immediately and cancel any pending hide runnables. */ private void hideFlyoutImmediate() { clearFlyoutOnHide(); mFlyout.removeCallbacks(mAnimateInFlyout); mFlyout.removeCallbacks(mHideFlyout); mFlyout.hideFlyout(); } private void clearFlyoutOnHide() { mFlyout.removeCallbacks(mAnimateInFlyout); if (mAfterFlyoutHidden == null) { return; } mAfterFlyoutHidden.run(); mAfterFlyoutHidden = null; } /** * Fills the Rect with the touchable region of the bubbles. This will be used by WindowManager * to decide which touch events go to Bubbles. * * Bubbles is below the status bar/notification shade but above application windows. If you're * trying to get touch events from the status bar or another higher-level window layer, you'll * need to re-order TYPE_BUBBLES in WindowManagerPolicy so that we have the opportunity to steal * them. */ public void getTouchableRegion(Rect outRect) { if (mUserEducationView != null && mUserEducationView.getVisibility() == VISIBLE) { // When user education shows then capture all touches outRect.set(0, 0, getWidth(), getHeight()); return; } if (!mIsExpanded) { if (getBubbleCount() > 0) { mBubbleContainer.getChildAt(0).getBoundsOnScreen(outRect); // Increase the touch target size of the bubble outRect.top -= mBubbleTouchPadding; outRect.left -= mBubbleTouchPadding; outRect.right += mBubbleTouchPadding; outRect.bottom += mBubbleTouchPadding; } } else { mBubbleContainer.getBoundsOnScreen(outRect); } if (mFlyout.getVisibility() == View.VISIBLE) { final Rect flyoutBounds = new Rect(); mFlyout.getBoundsOnScreen(flyoutBounds); outRect.union(flyoutBounds); } } private int getStatusBarHeight() { if (getRootWindowInsets() != null) { WindowInsets insets = getRootWindowInsets(); return Math.max( mStatusBarHeight, insets.getDisplayCutout() != null ? insets.getDisplayCutout().getSafeInsetTop() : 0); } return 0; } private void requestUpdate() { if (mViewUpdatedRequested || mIsExpansionAnimating) { return; } mViewUpdatedRequested = true; getViewTreeObserver().addOnPreDrawListener(mViewUpdater); invalidate(); } private void showManageMenu(boolean show) { mShowingManage = show; // This should not happen, since the manage menu is only visible when there's an expanded // bubble. If we end up in this state, just hide the menu immediately. if (mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) { mManageMenu.setVisibility(View.INVISIBLE); return; } // If available, update the manage menu's settings option with the expanded bubble's app // name and icon. if (show && mBubbleData.hasBubbleInStackWithKey(mExpandedBubble.getKey())) { final Bubble bubble = mBubbleData.getBubbleInStackWithKey(mExpandedBubble.getKey()); mManageSettingsIcon.setImageDrawable(bubble.getBadgedAppIcon()); mManageSettingsText.setText(getResources().getString( R.string.bubbles_app_settings, bubble.getAppName())); } mExpandedBubble.getExpandedView().getManageButtonBoundsOnScreen(mTempRect); final boolean isLtr = getResources().getConfiguration().getLayoutDirection() == LAYOUT_DIRECTION_LTR; // When the menu is open, it should be at these coordinates. The menu pops out to the right // in LTR and to the left in RTL. final float targetX = isLtr ? mTempRect.left : mTempRect.right - mManageMenu.getWidth(); final float targetY = mTempRect.bottom - mManageMenu.getHeight(); final float xOffsetForAnimation = (isLtr ? 1 : -1) * mManageMenu.getWidth() / 4f; if (show) { mManageMenu.setScaleX(0.5f); mManageMenu.setScaleY(0.5f); mManageMenu.setTranslationX(targetX - xOffsetForAnimation); mManageMenu.setTranslationY(targetY + mManageMenu.getHeight() / 4f); mManageMenu.setAlpha(0f); PhysicsAnimator.getInstance(mManageMenu) .spring(DynamicAnimation.ALPHA, 1f) .spring(DynamicAnimation.SCALE_X, 1f) .spring(DynamicAnimation.SCALE_Y, 1f) .spring(DynamicAnimation.TRANSLATION_X, targetX) .spring(DynamicAnimation.TRANSLATION_Y, targetY) .withEndActions(() -> { View child = mManageMenu.getChildAt(0); child.requestAccessibilityFocus(); }) .start(); mManageMenu.setVisibility(View.VISIBLE); } else { PhysicsAnimator.getInstance(mManageMenu) .spring(DynamicAnimation.ALPHA, 0f) .spring(DynamicAnimation.SCALE_X, 0.5f) .spring(DynamicAnimation.SCALE_Y, 0.5f) .spring(DynamicAnimation.TRANSLATION_X, targetX - xOffsetForAnimation) .spring(DynamicAnimation.TRANSLATION_Y, targetY + mManageMenu.getHeight() / 4f) .withEndActions(() -> mManageMenu.setVisibility(View.INVISIBLE)) .start(); } // Update the AV's obscured touchable region for the new menu visibility state. mExpandedBubble.getExpandedView().updateObscuredTouchableRegion(); } private void updateExpandedBubble() { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "updateExpandedBubble()"); } mExpandedViewContainer.removeAllViews(); if (mIsExpanded && mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { BubbleExpandedView bev = mExpandedBubble.getExpandedView(); bev.setContentVisibility(false); mExpandedViewContainerMatrix.setScaleX(0f); mExpandedViewContainerMatrix.setScaleY(0f); mExpandedViewContainerMatrix.setTranslate(0f, 0f); mExpandedViewContainer.setVisibility(View.INVISIBLE); mExpandedViewContainer.setAlpha(0f); mExpandedViewContainer.addView(bev); bev.setManageClickListener((view) -> showManageMenu(!mShowingManage)); bev.populateExpandedView(); if (!mIsExpansionAnimating) { mSurfaceSynchronizer.syncSurfaceAndRun(() -> { post(this::animateSwitchBubbles); }); } } } /** * Requests a snapshot from the currently expanded bubble's ActivityView and displays it in a * SurfaceView. This allows us to load a newly expanded bubble's Activity into the ActivityView, * while animating the (screenshot of the) previously selected bubble's content away. * * @param onComplete Callback to run once we're done here - called with 'false' if something * went wrong, or 'true' if the SurfaceView is now showing a screenshot of the * expanded bubble. */ private void screenshotAnimatingOutBubbleIntoSurface(Consumer onComplete) { if (!mIsExpanded || mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) { // You can't animate null. onComplete.accept(false); return; } final BubbleExpandedView animatingOutExpandedView = mExpandedBubble.getExpandedView(); // Release the previous screenshot if it hasn't been released already. if (mAnimatingOutBubbleBuffer != null) { releaseAnimatingOutBubbleBuffer(); } try { mAnimatingOutBubbleBuffer = animatingOutExpandedView.snapshotActivitySurface(); } catch (Exception e) { // If we fail for any reason, print the stack trace and then notify the callback of our // failure. This is not expected to occur, but it's not worth crashing over. Log.wtf(TAG, e); onComplete.accept(false); } if (mAnimatingOutBubbleBuffer == null || mAnimatingOutBubbleBuffer.getGraphicBuffer() == null) { // While no exception was thrown, we were unable to get a snapshot. onComplete.accept(false); return; } // Make sure the surface container's properties have been reset. PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel(); mAnimatingOutSurfaceContainer.setScaleX(1f); mAnimatingOutSurfaceContainer.setScaleY(1f); mAnimatingOutSurfaceContainer.setTranslationX(0); mAnimatingOutSurfaceContainer.setTranslationY(0); final int[] activityViewLocation = mExpandedBubble.getExpandedView().getActivityViewLocationOnScreen(); final int[] surfaceViewLocation = mAnimatingOutSurfaceView.getLocationOnScreen(); // Translate the surface to overlap the real ActivityView. mAnimatingOutSurfaceContainer.setTranslationY( activityViewLocation[1] - surfaceViewLocation[1]); // Set the width/height of the SurfaceView to match the snapshot. mAnimatingOutSurfaceView.getLayoutParams().width = mAnimatingOutBubbleBuffer.getGraphicBuffer().getWidth(); mAnimatingOutSurfaceView.getLayoutParams().height = mAnimatingOutBubbleBuffer.getGraphicBuffer().getHeight(); mAnimatingOutSurfaceView.requestLayout(); // Post to wait for layout. post(() -> { // The buffer might have been destroyed if the user is mashing on bubbles, that's okay. if (mAnimatingOutBubbleBuffer.getGraphicBuffer().isDestroyed()) { onComplete.accept(false); return; } if (!mIsExpanded) { onComplete.accept(false); return; } // Attach the buffer! We're now displaying the snapshot. mAnimatingOutSurfaceView.getHolder().getSurface().attachAndQueueBufferWithColorSpace( mAnimatingOutBubbleBuffer.getGraphicBuffer(), mAnimatingOutBubbleBuffer.getColorSpace()); mSurfaceSynchronizer.syncSurfaceAndRun(() -> post(() -> onComplete.accept(true))); }); } /** * Releases the buffer containing the screenshot of the animating-out bubble, if it exists and * isn't yet destroyed. */ private void releaseAnimatingOutBubbleBuffer() { if (mAnimatingOutBubbleBuffer != null && !mAnimatingOutBubbleBuffer.getGraphicBuffer().isDestroyed()) { mAnimatingOutBubbleBuffer.getGraphicBuffer().destroy(); } } private void updateExpandedView() { if (DEBUG_BUBBLE_STACK_VIEW) { Log.d(TAG, "updateExpandedView: mIsExpanded=" + mIsExpanded); } mExpandedViewContainer.setVisibility(mIsExpanded ? VISIBLE : GONE); if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) { mExpandedViewContainer.setTranslationY(getExpandedViewY()); mExpandedBubble.getExpandedView().updateView( mExpandedViewContainer.getLocationOnScreen()); } mStackOnLeftOrWillBe = mStackAnimationController.isStackOnLeftSide(); updateBubbleZOrdersAndDotPosition(false); } /** Sets the appropriate Z-order and dot position for each bubble in the stack. */ private void updateBubbleZOrdersAndDotPosition(boolean animate) { int bubbleCount = getBubbleCount(); for (int i = 0; i < bubbleCount; i++) { BadgedImageView bv = (BadgedImageView) mBubbleContainer.getChildAt(i); bv.setZ((mMaxBubbles * mBubbleElevation) - i); // If the dot is on the left, and so is the stack, we need to change the dot position. if (bv.getDotPositionOnLeft() == mStackOnLeftOrWillBe) { bv.setDotPositionOnLeft(!mStackOnLeftOrWillBe, animate); } if (!mIsExpanded && i > 0) { // If we're collapsed and this bubble is behind other bubbles, suppress its dot. bv.addDotSuppressionFlag( BadgedImageView.SuppressionFlag.BEHIND_STACK); } else { bv.removeDotSuppressionFlag( BadgedImageView.SuppressionFlag.BEHIND_STACK); } } } private void updatePointerPosition() { if (mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) { return; } int index = getBubbleIndex(mExpandedBubble); if (index == -1) { return; } float bubbleLeftFromScreenLeft = mExpandedAnimationController.getBubbleLeft(index); float halfBubble = mBubbleSize / 2f; float bubbleCenter = bubbleLeftFromScreenLeft + halfBubble; // Padding might be adjusted for insets, so get it directly from the view bubbleCenter -= mExpandedViewContainer.getPaddingLeft(); mExpandedBubble.getExpandedView().setPointerPosition(bubbleCenter); } /** * @return the number of bubbles in the stack view. */ public int getBubbleCount() { // Subtract 1 for the overflow button that is always in the bubble container. return mBubbleContainer.getChildCount() - 1; } /** * Finds the bubble index within the stack. * * @param provider the bubble view provider with the bubble to look up. * @return the index of the bubble view within the bubble stack. The range of the position * is between 0 and the bubble count minus 1. */ int getBubbleIndex(@Nullable BubbleViewProvider provider) { if (provider == null) { return 0; } return mBubbleContainer.indexOfChild(provider.getIconView()); } /** * @return the normalized x-axis position of the bubble stack rounded to 4 decimal places. */ public float getNormalizedXPosition() { return new BigDecimal(getStackPosition().x / mDisplaySize.x) .setScale(4, RoundingMode.CEILING.HALF_UP) .floatValue(); } /** * @return the normalized y-axis position of the bubble stack rounded to 4 decimal places. */ public float getNormalizedYPosition() { return new BigDecimal(getStackPosition().y / mDisplaySize.y) .setScale(4, RoundingMode.CEILING.HALF_UP) .floatValue(); } public PointF getStackPosition() { return mStackAnimationController.getStackPosition(); } /** * Logs the bubble UI event. * * @param provider the bubble view provider that is being interacted on. Null value indicates * that the user interaction is not specific to one bubble. * @param action the user interaction enum. */ private void logBubbleEvent(@Nullable BubbleViewProvider provider, int action) { if (provider == null || provider.getKey().equals(BubbleOverflow.KEY)) { SysUiStatsLog.write(SysUiStatsLog.BUBBLE_UI_CHANGED, mContext.getApplicationInfo().packageName, provider == null ? null : BubbleOverflow.KEY /* notification channel */, 0 /* notification ID */, 0 /* bubble position */, getBubbleCount(), action, getNormalizedXPosition(), getNormalizedYPosition(), false /* unread bubble */, false /* on-going bubble */, false /* isAppForeground (unused) */); return; } provider.logUIEvent(getBubbleCount(), action, getNormalizedXPosition(), getNormalizedYPosition(), getBubbleIndex(provider)); } /** * Called when a back gesture should be directed to the Bubbles stack. When expanded, * a back key down/up event pair is forwarded to the bubble Activity. */ boolean performBackPressIfNeeded() { if (!isExpanded() || mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) { return false; } return mExpandedBubble.getExpandedView().performBackPressIfNeeded(); } /** Whether the educational view should appear for bubbles. **/ private boolean shouldShowBubblesEducation() { return BubbleDebugConfig.forceShowUserEducation(getContext()) || !Prefs.getBoolean(getContext(), HAS_SEEN_BUBBLES_EDUCATION, false); } /** Whether the educational view should appear for the expanded view "manage" button. **/ private boolean shouldShowManageEducation() { return BubbleDebugConfig.forceShowUserEducation(getContext()) || !Prefs.getBoolean(getContext(), HAS_SEEN_BUBBLES_MANAGE_EDUCATION, false); } /** For debugging only */ List getBubblesOnScreen() { List bubbles = new ArrayList<>(); for (int i = 0; i < getBubbleCount(); i++) { View child = mBubbleContainer.getChildAt(i); if (child instanceof BadgedImageView) { String key = ((BadgedImageView) child).getKey(); Bubble bubble = mBubbleData.getBubbleInStackWithKey(key); bubbles.add(bubble); } } return bubbles; } }