/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.wm.shell.bubbles; import static android.app.ActivityTaskManager.INVALID_TASK_ID; import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK; import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_EXPANDED_VIEW; import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES; import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME; import static com.android.wm.shell.bubbles.BubblePositioner.MAX_HEIGHT; import android.annotation.NonNull; import android.annotation.SuppressLint; import android.app.ActivityOptions; import android.app.ActivityTaskManager; import android.app.PendingIntent; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.CornerPathEffect; import android.graphics.Outline; import android.graphics.Paint; import android.graphics.Picture; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.drawable.ShapeDrawable; import android.os.RemoteException; import android.util.AttributeSet; import android.util.FloatProperty; import android.util.IntProperty; import android.util.Log; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.SurfaceControl; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.FrameLayout; import android.widget.LinearLayout; import androidx.annotation.Nullable; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.policy.ScreenDecorationsUtils; import com.android.wm.shell.R; import com.android.wm.shell.TaskView; import com.android.wm.shell.common.AlphaOptimizedButton; import com.android.wm.shell.common.TriangleShape; import java.io.PrintWriter; /** * Container for the expanded bubble view, handles rendering the caret and settings icon. */ public class BubbleExpandedView extends LinearLayout { private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleExpandedView" : TAG_BUBBLES; /** {@link IntProperty} for updating bottom clip */ public static final IntProperty BOTTOM_CLIP_PROPERTY = new IntProperty("bottomClip") { @Override public void setValue(BubbleExpandedView expandedView, int value) { expandedView.setBottomClip(value); } @Override public Integer get(BubbleExpandedView expandedView) { return expandedView.mBottomClip; } }; /** {@link FloatProperty} for updating taskView or overflow alpha */ public static final FloatProperty CONTENT_ALPHA = new FloatProperty("contentAlpha") { @Override public void setValue(BubbleExpandedView expandedView, float value) { expandedView.setContentAlpha(value); } @Override public Float get(BubbleExpandedView expandedView) { return expandedView.getContentAlpha(); } }; /** {@link FloatProperty} for updating background and pointer alpha */ public static final FloatProperty BACKGROUND_ALPHA = new FloatProperty("backgroundAlpha") { @Override public void setValue(BubbleExpandedView expandedView, float value) { expandedView.setBackgroundAlpha(value); } @Override public Float get(BubbleExpandedView expandedView) { return expandedView.getAlpha(); } }; /** {@link FloatProperty} for updating manage button alpha */ public static final FloatProperty MANAGE_BUTTON_ALPHA = new FloatProperty("manageButtonAlpha") { @Override public void setValue(BubbleExpandedView expandedView, float value) { expandedView.mManageButton.setAlpha(value); } @Override public Float get(BubbleExpandedView expandedView) { return expandedView.mManageButton.getAlpha(); } }; // The triangle pointing to the expanded view private View mPointerView; @Nullable private int[] mExpandedViewContainerLocation; private AlphaOptimizedButton mManageButton; private TaskView mTaskView; private BubbleOverflowContainerView mOverflowView; private int mTaskId = INVALID_TASK_ID; private boolean mImeVisible; private boolean mNeedsNewHeight; /** * Whether we want the {@code TaskView}'s content to be visible (alpha = 1f). If * {@link #mIsAnimating} is true, this may not reflect the {@code TaskView}'s actual alpha * value until the animation ends. */ private boolean mIsContentVisible = false; /** * Whether we're animating the {@code TaskView}'s alpha value. If so, we will hold off on * applying alpha changes from {@link #setContentVisibility} until the animation ends. */ private boolean mIsAnimating = false; private int mPointerWidth; private int mPointerHeight; private float mPointerRadius; private float mPointerOverlap; private final PointF mPointerPos = new PointF(); private CornerPathEffect mPointerEffect; private ShapeDrawable mCurrentPointer; private ShapeDrawable mTopPointer; private ShapeDrawable mLeftPointer; private ShapeDrawable mRightPointer; private float mCornerRadius = 0f; private int mBackgroundColorFloating; private boolean mUsingMaxHeight; private int mTopClip = 0; private int mBottomClip = 0; @Nullable private Bubble mBubble; private PendingIntent mPendingIntent; // TODO(b/170891664): Don't use a flag, set the BubbleOverflow object instead private boolean mIsOverflow; private boolean mIsClipping; private BubbleController mController; private BubbleStackView mStackView; private BubblePositioner mPositioner; /** * Container for the {@code TaskView} that has a solid, round-rect background that shows if the * {@code TaskView} hasn't loaded. */ private final FrameLayout mExpandedViewContainer = new FrameLayout(getContext()); private final TaskView.Listener mTaskViewListener = new TaskView.Listener() { private boolean mInitialized = false; private boolean mDestroyed = false; @Override public void onInitialized() { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "onInitialized: destroyed=" + mDestroyed + " initialized=" + mInitialized + " bubble=" + getBubbleKey()); } if (mDestroyed || mInitialized) { return; } // Custom options so there is no activity transition animation ActivityOptions options = ActivityOptions.makeCustomAnimation(getContext(), 0 /* enterResId */, 0 /* exitResId */); Rect launchBounds = new Rect(); mTaskView.getBoundsOnScreen(launchBounds); // TODO: I notice inconsistencies in lifecycle // Post to keep the lifecycle normal post(() -> { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "onInitialized: calling startActivity, bubble=" + getBubbleKey()); } try { options.setTaskAlwaysOnTop(true); options.setLaunchedFromBubble(true); Intent fillInIntent = new Intent(); // Apply flags to make behaviour match documentLaunchMode=always. fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT); fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK); if (mBubble.isAppBubble()) { PendingIntent pi = PendingIntent.getActivity(mContext, 0, mBubble.getAppBubbleIntent(), PendingIntent.FLAG_MUTABLE, null); mTaskView.startActivity(pi, fillInIntent, options, launchBounds); } else if (!mIsOverflow && mBubble.hasMetadataShortcutId()) { options.setApplyActivityFlagsForBubbles(true); mTaskView.startShortcutActivity(mBubble.getShortcutInfo(), options, launchBounds); } else { if (mBubble != null) { mBubble.setIntentActive(); } mTaskView.startActivity(mPendingIntent, fillInIntent, options, launchBounds); } } catch (RuntimeException e) { // If there's a runtime exception here then there's something // wrong with the intent, we can't really recover / try to populate // the bubble again so we'll just remove it. Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey() + ", " + e.getMessage() + "; removing bubble"); mController.removeBubble(getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT); } }); mInitialized = true; } @Override public void onReleased() { mDestroyed = true; } @Override public void onTaskCreated(int taskId, ComponentName name) { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "onTaskCreated: taskId=" + taskId + " bubble=" + getBubbleKey()); } // The taskId is saved to use for removeTask, preventing appearance in recent tasks. mTaskId = taskId; // With the task org, the taskAppeared callback will only happen once the task has // already drawn setContentVisibility(true); } @Override public void onTaskVisibilityChanged(int taskId, boolean visible) { setContentVisibility(visible); } @Override public void onTaskRemovalStarted(int taskId) { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "onTaskRemovalStarted: taskId=" + taskId + " bubble=" + getBubbleKey()); } if (mBubble != null) { // Must post because this is called from a binder thread. post(() -> mController.removeBubble( mBubble.getKey(), Bubbles.DISMISS_TASK_FINISHED)); } } @Override public void onBackPressedOnTaskRoot(int taskId) { if (mTaskId == taskId && mStackView.isExpanded()) { mStackView.onBackPressed(); } } }; public BubbleExpandedView(Context context) { this(context, null); } public BubbleExpandedView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @SuppressLint("ClickableViewAccessibility") @Override protected void onFinishInflate() { super.onFinishInflate(); mManageButton = (AlphaOptimizedButton) LayoutInflater.from(getContext()).inflate( R.layout.bubble_manage_button, this /* parent */, false /* attach */); updateDimensions(); mPointerView = findViewById(R.id.pointer_view); mCurrentPointer = mTopPointer; mPointerView.setVisibility(INVISIBLE); // Set {@code TaskView}'s alpha value as zero, since there is no view content to be shown. setContentVisibility(false); mExpandedViewContainer.setOutlineProvider(new ViewOutlineProvider() { @Override public void getOutline(View view, Outline outline) { Rect clip = new Rect(0, mTopClip, view.getWidth(), view.getHeight() - mBottomClip); outline.setRoundRect(clip, mCornerRadius); } }); mExpandedViewContainer.setClipToOutline(true); mExpandedViewContainer.setLayoutParams( new ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); addView(mExpandedViewContainer); // Expanded stack layout, top to bottom: // Expanded view container // ==> bubble row // ==> expanded view // ==> activity view // ==> manage button bringChildToFront(mManageButton); applyThemeAttrs(); setClipToPadding(false); setOnTouchListener((view, motionEvent) -> { if (mTaskView == null) { return false; } final Rect avBounds = new Rect(); mTaskView.getBoundsOnScreen(avBounds); // Consume and ignore events on the expanded view padding that are within the // {@code TaskView}'s vertical bounds. These events are part of a back gesture, and so // they should not collapse the stack (which all other touches on areas around the AV // would do). if (motionEvent.getRawY() >= avBounds.top && motionEvent.getRawY() <= avBounds.bottom && (motionEvent.getRawX() < avBounds.left || motionEvent.getRawX() > avBounds.right)) { return true; } return false; }); // BubbleStackView is forced LTR, but we want to respect the locale for expanded view layout // so the Manage button appears on the right. setLayoutDirection(LAYOUT_DIRECTION_LOCALE); } /** * Initialize {@link BubbleController} and {@link BubbleStackView} here, this method must need * to be called after view inflate. */ void initialize(BubbleController controller, BubbleStackView stackView, boolean isOverflow) { mController = controller; mStackView = stackView; mIsOverflow = isOverflow; mPositioner = mController.getPositioner(); if (mIsOverflow) { mOverflowView = (BubbleOverflowContainerView) LayoutInflater.from(getContext()).inflate( R.layout.bubble_overflow_container, null /* root */); mOverflowView.setBubbleController(mController); FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT); mExpandedViewContainer.addView(mOverflowView, lp); mExpandedViewContainer.setLayoutParams( new LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)); bringChildToFront(mOverflowView); mManageButton.setVisibility(GONE); } else { mTaskView = new TaskView(mContext, mController.getTaskOrganizer(), mController.getTaskViewTransitions(), mController.getSyncTransactionQueue()); mTaskView.setListener(mController.getMainExecutor(), mTaskViewListener); mExpandedViewContainer.addView(mTaskView); bringChildToFront(mTaskView); } } void updateDimensions() { Resources res = getResources(); updateFontSize(); mPointerWidth = res.getDimensionPixelSize(R.dimen.bubble_pointer_width); mPointerHeight = res.getDimensionPixelSize(R.dimen.bubble_pointer_height); mPointerRadius = getResources().getDimensionPixelSize(R.dimen.bubble_pointer_radius); mPointerEffect = new CornerPathEffect(mPointerRadius); mPointerOverlap = getResources().getDimensionPixelSize(R.dimen.bubble_pointer_overlap); mTopPointer = new ShapeDrawable(TriangleShape.create( mPointerWidth, mPointerHeight, true /* pointUp */)); mLeftPointer = new ShapeDrawable(TriangleShape.createHorizontal( mPointerWidth, mPointerHeight, true /* pointLeft */)); mRightPointer = new ShapeDrawable(TriangleShape.createHorizontal( mPointerWidth, mPointerHeight, false /* pointLeft */)); if (mPointerView != null) { updatePointerView(); } if (mManageButton != null) { int visibility = mManageButton.getVisibility(); removeView(mManageButton); mManageButton = (AlphaOptimizedButton) LayoutInflater.from(getContext()).inflate( R.layout.bubble_manage_button, this /* parent */, false /* attach */); addView(mManageButton); mManageButton.setVisibility(visibility); } } void updateFontSize() { final float fontSize = mContext.getResources() .getDimensionPixelSize(com.android.internal.R.dimen.text_size_body_2_material); if (mManageButton != null) { mManageButton.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize); } if (mOverflowView != null) { mOverflowView.updateFontSize(); } } void applyThemeAttrs() { final TypedArray ta = mContext.obtainStyledAttributes(new int[]{ android.R.attr.dialogCornerRadius, android.R.attr.colorBackgroundFloating}); boolean supportsRoundedCorners = ScreenDecorationsUtils.supportsRoundedCornersOnWindows( mContext.getResources()); mCornerRadius = supportsRoundedCorners ? ta.getDimensionPixelSize(0, 0) : 0; mBackgroundColorFloating = ta.getColor(1, Color.WHITE); mExpandedViewContainer.setBackgroundColor(mBackgroundColorFloating); ta.recycle(); if (mTaskView != null) { mTaskView.setCornerRadius(mCornerRadius); } updatePointerView(); } /** Updates the size and visuals of the pointer. **/ private void updatePointerView() { LayoutParams lp = (LayoutParams) mPointerView.getLayoutParams(); if (mCurrentPointer == mLeftPointer || mCurrentPointer == mRightPointer) { lp.width = mPointerHeight; lp.height = mPointerWidth; } else { lp.width = mPointerWidth; lp.height = mPointerHeight; } mCurrentPointer.setTint(mBackgroundColorFloating); Paint arrowPaint = mCurrentPointer.getPaint(); arrowPaint.setColor(mBackgroundColorFloating); arrowPaint.setPathEffect(mPointerEffect); mPointerView.setLayoutParams(lp); mPointerView.setBackground(mCurrentPointer); } @VisibleForTesting public String getBubbleKey() { return mBubble != null ? mBubble.getKey() : mIsOverflow ? BubbleOverflow.KEY : null; } /** * Sets whether the surface displaying app content should sit on top. This is useful for * ordering surfaces during animations. When content is drawn on top of the app (e.g. bubble * being dragged out, the manage menu) this is set to false, otherwise it should be true. */ public void setSurfaceZOrderedOnTop(boolean onTop) { if (mTaskView == null) { return; } mTaskView.setZOrderedOnTop(onTop, true /* allowDynamicChange */); } void setImeVisible(boolean visible) { mImeVisible = visible; if (!mImeVisible && mNeedsNewHeight) { updateHeight(); } } /** Return a GraphicBuffer with the contents of the task view surface. */ @Nullable SurfaceControl.ScreenshotHardwareBuffer snapshotActivitySurface() { if (mIsOverflow) { // For now, just snapshot the view and return it as a hw buffer so that the animation // code for both the tasks and overflow can be the same Picture p = new Picture(); mOverflowView.draw( p.beginRecording(mOverflowView.getWidth(), mOverflowView.getHeight())); p.endRecording(); Bitmap snapshot = Bitmap.createBitmap(p); return new SurfaceControl.ScreenshotHardwareBuffer( snapshot.getHardwareBuffer(), snapshot.getColorSpace(), false /* containsSecureLayers */, false /* containsHdrLayers */); } if (mTaskView == null || mTaskView.getSurfaceControl() == null) { return null; } return SurfaceControl.captureLayers( mTaskView.getSurfaceControl(), new Rect(0, 0, mTaskView.getWidth(), mTaskView.getHeight()), 1 /* scale */); } int[] getTaskViewLocationOnScreen() { if (mIsOverflow) { // This is only used for animating away the surface when switching bubbles, just use the // view location on screen for now to allow us to use the same animation code with tasks return mOverflowView.getLocationOnScreen(); } if (mTaskView != null) { return mTaskView.getLocationOnScreen(); } else { return new int[]{0, 0}; } } // TODO: Could listener be passed when we pass StackView / can we avoid setting this like this void setManageClickListener(OnClickListener manageClickListener) { mManageButton.setOnClickListener(manageClickListener); } /** * Updates the obscured touchable region for the task surface. This calls onLocationChanged, * which results in a call to {@link BubbleStackView#subtractObscuredTouchableRegion}. This is * useful if a view has been added or removed from on top of the {@code TaskView}, such as the * manage menu. */ void updateObscuredTouchableRegion() { if (mTaskView != null) { mTaskView.onLocationChanged(); } } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); mImeVisible = false; mNeedsNewHeight = false; if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "onDetachedFromWindow: bubble=" + getBubbleKey()); } } /** * Whether we are currently animating the {@code TaskView}. If this is set to * true, calls to {@link #setContentVisibility} will not be applied until this is set to false * again. */ public void setAnimating(boolean animating) { mIsAnimating = animating; // If we're done animating, apply the correct if (!animating) { setContentVisibility(mIsContentVisible); } } /** * Get alpha from underlying {@code TaskView} if this view is for a bubble. * Or get alpha for the overflow view if this view is for overflow. * * @return alpha for the content being shown */ public float getContentAlpha() { if (mIsOverflow) { return mOverflowView.getAlpha(); } if (mTaskView != null) { return mTaskView.getAlpha(); } return 1f; } /** * Set alpha of the underlying {@code TaskView} if this view is for a bubble. * Or set alpha for the overflow view if this view is for overflow. * * Changing expanded view's alpha does not affect the {@code TaskView} since it uses a Surface. */ public void setContentAlpha(float alpha) { if (mIsOverflow) { mOverflowView.setAlpha(alpha); } else if (mTaskView != null) { mTaskView.setAlpha(alpha); } } /** * Sets the alpha of the background and the pointer view. */ public void setBackgroundAlpha(float alpha) { mPointerView.setAlpha(alpha); setAlpha(alpha); } /** * Set translation Y for the expanded view content. * Excludes manage button and pointer. */ public void setContentTranslationY(float translationY) { mExpandedViewContainer.setTranslationY(translationY); // Left or right pointer can become detached when moving the view up if (translationY <= 0 && (isShowingLeftPointer() || isShowingRightPointer())) { // Y coordinate where the pointer would start to get detached from the expanded view. // Takes into account bottom clipping and rounded corners float detachPoint = mExpandedViewContainer.getBottom() - mBottomClip - mCornerRadius + translationY; float pointerBottom = mPointerPos.y + mPointerHeight; // If pointer bottom is past detach point, move it in by that many pixels float horizontalShift = 0; if (pointerBottom > detachPoint) { horizontalShift = pointerBottom - detachPoint; } if (isShowingLeftPointer()) { // Move left pointer right movePointerBy(horizontalShift, 0); } else { // Move right pointer left movePointerBy(-horizontalShift, 0); } // Hide pointer if it is moved by entire width mPointerView.setVisibility( horizontalShift > mPointerWidth ? View.INVISIBLE : View.VISIBLE); } } /** * Update alpha value for the manage button */ public void setManageButtonAlpha(float alpha) { mManageButton.setAlpha(alpha); } /** * Set {@link #setTranslationY(float) translationY} for the manage button */ public void setManageButtonTranslationY(float translationY) { mManageButton.setTranslationY(translationY); } /** * Set top clipping for the view */ public void setTopClip(int clip) { mTopClip = clip; onContainerClipUpdate(); } /** * Set bottom clipping for the view */ public void setBottomClip(int clip) { mBottomClip = clip; onContainerClipUpdate(); } private void onContainerClipUpdate() { if (mTopClip == 0 && mBottomClip == 0) { if (mIsClipping) { mIsClipping = false; if (mTaskView != null) { mTaskView.setClipBounds(null); mTaskView.setEnableSurfaceClipping(false); } mExpandedViewContainer.invalidateOutline(); } } else { if (!mIsClipping) { mIsClipping = true; if (mTaskView != null) { mTaskView.setEnableSurfaceClipping(true); } } mExpandedViewContainer.invalidateOutline(); if (mTaskView != null) { mTaskView.setClipBounds(new Rect(0, mTopClip, mTaskView.getWidth(), mTaskView.getHeight() - mBottomClip)); } } } /** * Move pointer from base position */ public void movePointerBy(float x, float y) { mPointerView.setTranslationX(mPointerPos.x + x); mPointerView.setTranslationY(mPointerPos.y + y); } /** * Set visibility of contents in the expanded state. * * @param visibility {@code true} if the contents should be visible on the screen. * * Note that this contents visibility doesn't affect visibility at {@link android.view.View}, * and setting {@code false} actually means rendering the contents in transparent. */ public void setContentVisibility(boolean visibility) { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "setContentVisibility: visibility=" + visibility + " bubble=" + getBubbleKey()); } mIsContentVisible = visibility; if (mTaskView != null && !mIsAnimating) { mTaskView.setAlpha(visibility ? 1f : 0f); mPointerView.setAlpha(visibility ? 1f : 0f); } } @Nullable TaskView getTaskView() { return mTaskView; } @VisibleForTesting public BubbleOverflowContainerView getOverflow() { return mOverflowView; } /** * Return content height: taskView or overflow. * Takes into account clippings set by {@link #setTopClip(int)} and {@link #setBottomClip(int)} * * @return if bubble is for overflow, return overflow height, otherwise return taskView height */ public int getContentHeight() { if (mIsOverflow) { return mOverflowView.getHeight() - mTopClip - mBottomClip; } if (mTaskView != null) { return mTaskView.getHeight() - mTopClip - mBottomClip; } return 0; } /** * Return bottom position of the content on screen * * @return if bubble is for overflow, return value for overflow, otherwise taskView */ public int getContentBottomOnScreen() { Rect out = new Rect(); if (mIsOverflow) { mOverflowView.getBoundsOnScreen(out); } if (mTaskView != null) { mTaskView.getBoundsOnScreen(out); } return out.bottom; } int getTaskId() { return mTaskId; } /** * Sets the bubble used to populate this view. */ void update(Bubble bubble) { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "update: bubble=" + bubble); } if (mStackView == null) { Log.w(TAG, "Stack is null for bubble: " + bubble); return; } boolean isNew = mBubble == null || didBackingContentChange(bubble); if (isNew || bubble != null && bubble.getKey().equals(mBubble.getKey())) { mBubble = bubble; mManageButton.setContentDescription(getResources().getString( R.string.bubbles_settings_button_description, bubble.getAppName())); mManageButton.setAccessibilityDelegate( new AccessibilityDelegate() { @Override public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(host, info); // On focus, have TalkBack say // "Actions available. Use swipe up then right to view." // in addition to the default "double tap to activate". mStackView.setupLocalMenu(info); } }); if (isNew) { mPendingIntent = mBubble.getBubbleIntent(); if ((mPendingIntent != null || mBubble.hasMetadataShortcutId()) && mTaskView != null) { setContentVisibility(false); mTaskView.setVisibility(VISIBLE); } } applyThemeAttrs(); } else { Log.w(TAG, "Trying to update entry with different key, new bubble: " + bubble.getKey() + " old bubble: " + bubble.getKey()); } } /** * Bubbles are backed by a pending intent or a shortcut, once the activity is * started we never change it / restart it on notification updates -- unless the bubbles' * backing data switches. * * This indicates if the new bubble is backed by a different data source than what was * previously shown here (e.g. previously a pending intent & now a shortcut). * * @param newBubble the bubble this view is being updated with. * @return true if the backing content has changed. */ private boolean didBackingContentChange(Bubble newBubble) { boolean prevWasIntentBased = mBubble != null && mPendingIntent != null; boolean newIsIntentBased = newBubble.getBubbleIntent() != null; return prevWasIntentBased != newIsIntentBased; } /** * Whether the bubble is using all available height to display or not. */ public boolean isUsingMaxHeight() { return mUsingMaxHeight; } void updateHeight() { if (mExpandedViewContainerLocation == null) { return; } if ((mBubble != null && mTaskView != null) || mIsOverflow) { float desiredHeight = mPositioner.getExpandedViewHeight(mBubble); int maxHeight = mPositioner.getMaxExpandedViewHeight(mIsOverflow); float height = desiredHeight == MAX_HEIGHT ? maxHeight : Math.min(desiredHeight, maxHeight); mUsingMaxHeight = height == maxHeight; FrameLayout.LayoutParams lp = mIsOverflow ? (FrameLayout.LayoutParams) mOverflowView.getLayoutParams() : (FrameLayout.LayoutParams) mTaskView.getLayoutParams(); mNeedsNewHeight = lp.height != height; if (!mImeVisible) { // If the ime is visible... don't adjust the height because that will cause // a configuration change and the ime will be lost. lp.height = (int) height; if (mIsOverflow) { mOverflowView.setLayoutParams(lp); } else { mTaskView.setLayoutParams(lp); } mNeedsNewHeight = false; } if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "updateHeight: bubble=" + getBubbleKey() + " height=" + height + " mNeedsNewHeight=" + mNeedsNewHeight); } } } /** * Update appearance of the expanded view being displayed. * * @param containerLocationOnScreen The location on-screen of the container the expanded view is * added to. This allows us to calculate max height without * waiting for layout. */ public void updateView(int[] containerLocationOnScreen) { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "updateView: bubble=" + getBubbleKey()); } mExpandedViewContainerLocation = containerLocationOnScreen; updateHeight(); if (mTaskView != null && mTaskView.getVisibility() == VISIBLE && mTaskView.isAttachedToWindow()) { mTaskView.onLocationChanged(); } if (mIsOverflow) { post(() -> { mOverflowView.show(); }); } } /** * Sets the position of the pointer. * * When bubbles are showing "vertically" they display along the left / right sides of the * screen with the expanded view beside them. * * If they aren't showing vertically they're positioned along the top of the screen with the * expanded view below them. * * @param bubblePosition the x position of the bubble if showing on top, the y position of * the bubble if showing vertically. * @param onLeft whether the stack was on the left side of the screen when expanded. * @param animate whether the pointer should animate to this position. */ public void setPointerPosition(float bubblePosition, boolean onLeft, boolean animate) { final boolean isRtl = mContext.getResources().getConfiguration().getLayoutDirection() == LAYOUT_DIRECTION_RTL; // Pointer gets drawn in the padding final boolean showVertically = mPositioner.showBubblesVertically(); final float paddingLeft = (showVertically && onLeft) ? mPointerHeight - mPointerOverlap : 0; final float paddingRight = (showVertically && !onLeft) ? mPointerHeight - mPointerOverlap : 0; final float paddingTop = showVertically ? 0 : mPointerHeight - mPointerOverlap; setPadding((int) paddingLeft, (int) paddingTop, (int) paddingRight, 0); // Subtract the expandedViewY here because the pointer is placed within the expandedView. float pointerPosition = mPositioner.getPointerPosition(bubblePosition); final float bubbleCenter = mPositioner.showBubblesVertically() ? pointerPosition - mPositioner.getExpandedViewY(mBubble, bubblePosition) : pointerPosition; // Post because we need the width of the view post(() -> { mCurrentPointer = showVertically ? onLeft ? mLeftPointer : mRightPointer : mTopPointer; updatePointerView(); if (showVertically) { mPointerPos.y = bubbleCenter - (mPointerWidth / 2f); if (!isRtl) { mPointerPos.x = onLeft ? -mPointerHeight + mPointerOverlap : getWidth() - mPaddingRight - mPointerOverlap; } else { mPointerPos.x = onLeft ? -(getWidth() - mPaddingLeft - mPointerOverlap) : mPointerHeight - mPointerOverlap; } } else { mPointerPos.y = mPointerOverlap; if (!isRtl) { mPointerPos.x = bubbleCenter - (mPointerWidth / 2f); } else { mPointerPos.x = -(getWidth() - mPaddingLeft - bubbleCenter) + (mPointerWidth / 2f); } } if (animate) { mPointerView.animate().translationX(mPointerPos.x).translationY( mPointerPos.y).start(); } else { mPointerView.setTranslationY(mPointerPos.y); mPointerView.setTranslationX(mPointerPos.x); mPointerView.setVisibility(VISIBLE); } }); } /** * Return true if pointer is shown on the left */ public boolean isShowingLeftPointer() { return mCurrentPointer == mLeftPointer; } /** * Return true if pointer is shown on the right */ public boolean isShowingRightPointer() { return mCurrentPointer == mRightPointer; } /** * Return width of the current pointer */ public int getPointerWidth() { return mPointerWidth; } /** * Position of the manage button displayed in the expanded view. Used for placing user * education about the manage button. */ public void getManageButtonBoundsOnScreen(Rect rect) { mManageButton.getBoundsOnScreen(rect); } public int getManageButtonMargin() { return ((LinearLayout.LayoutParams) mManageButton.getLayoutParams()).getMarginStart(); } /** * Cleans up anything related to the task and {@code TaskView}. If this view should be reused * after this method is called, then * {@link #initialize(BubbleController, BubbleStackView, boolean)} must be invoked first. */ public void cleanUpExpandedState() { if (DEBUG_BUBBLE_EXPANDED_VIEW) { Log.d(TAG, "cleanUpExpandedState: bubble=" + getBubbleKey() + " task=" + mTaskId); } if (getTaskId() != INVALID_TASK_ID) { try { ActivityTaskManager.getService().removeTask(getTaskId()); } catch (RemoteException e) { Log.w(TAG, e.getMessage()); } } if (mTaskView != null) { mTaskView.release(); removeView(mTaskView); mTaskView = null; } } /** * Description of current expanded view state. */ public void dump(@NonNull PrintWriter pw) { pw.print("BubbleExpandedView"); pw.print(" taskId: "); pw.println(mTaskId); pw.print(" stackView: "); pw.println(mStackView); } }