diff options
11 files changed, 799 insertions, 18 deletions
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java index e8e25e20d8d8..e68a98fb7f21 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java @@ -28,6 +28,7 @@ import android.annotation.Nullable; import android.app.Notification; import android.app.PendingIntent; import android.app.Person; +import android.app.TaskInfo; import android.content.Context; import android.content.Intent; import android.content.LocusId; @@ -57,6 +58,7 @@ import com.android.wm.shell.shared.annotations.ShellBackgroundThread; import com.android.wm.shell.shared.annotations.ShellMainThread; import com.android.wm.shell.shared.bubbles.BubbleInfo; import com.android.wm.shell.shared.bubbles.ParcelableFlyoutMessage; +import com.android.wm.shell.taskview.TaskView; import java.io.PrintWriter; import java.util.List; @@ -204,6 +206,13 @@ public class Bubble implements BubbleViewProvider { private Intent mAppIntent; /** + * Set while preparing a transition for animation. Several steps are needed before animation + * starts, so this is used to detect and route associated events to the coordinating transition. + */ + @Nullable + private BubbleTransitions.BubbleTransition mPreparingTransition; + + /** * Create a bubble with limited information based on given {@link ShortcutInfo}. * Note: Currently this is only being used when the bubble is persisted to disk. */ @@ -280,6 +289,30 @@ public class Bubble implements BubbleViewProvider { mShortcutInfo = info; } + private Bubble( + TaskInfo task, + UserHandle user, + @Nullable Icon icon, + String key, + @ShellMainThread Executor mainExecutor, + @ShellBackgroundThread Executor bgExecutor) { + mGroupKey = null; + mLocusId = null; + mFlags = 0; + mUser = user; + mIcon = icon; + mIsAppBubble = true; + mKey = key; + mShowBubbleUpdateDot = false; + mMainExecutor = mainExecutor; + mBgExecutor = bgExecutor; + mTaskId = task.taskId; + mAppIntent = null; + mDesiredHeight = Integer.MAX_VALUE; + mPackageName = task.baseActivity.getPackageName(); + } + + /** Creates an app bubble. */ public static Bubble createAppBubble(Intent intent, UserHandle user, @Nullable Icon icon, @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) { @@ -291,6 +324,16 @@ public class Bubble implements BubbleViewProvider { mainExecutor, bgExecutor); } + /** Creates a task bubble. */ + public static Bubble createTaskBubble(TaskInfo info, UserHandle user, @Nullable Icon icon, + @ShellMainThread Executor mainExecutor, @ShellBackgroundThread Executor bgExecutor) { + return new Bubble(info, + user, + icon, + getAppBubbleKeyForTask(info), + mainExecutor, bgExecutor); + } + /** Creates a shortcut bubble. */ public static Bubble createShortcutBubble( ShortcutInfo info, @@ -316,6 +359,15 @@ public class Bubble implements BubbleViewProvider { return info.getPackage() + ":" + info.getUserId() + ":" + info.getId(); } + /** + * Returns the key for an app bubble from an app with package name, {@code packageName} on an + * Android user, {@code user}. + */ + public static String getAppBubbleKeyForTask(TaskInfo taskInfo) { + Objects.requireNonNull(taskInfo); + return KEY_APP_BUBBLE + ":" + taskInfo.taskId; + } + @VisibleForTesting(visibility = PRIVATE) public Bubble(@NonNull final BubbleEntry entry, final Bubbles.BubbleMetadataFlagListener listener, @@ -469,6 +521,10 @@ public class Bubble implements BubbleViewProvider { return mBubbleTaskView; } + public TaskView getTaskView() { + return mBubbleTaskView.getTaskView(); + } + /** * @return the ShortcutInfo id if it exists, or the metadata shortcut id otherwise. */ @@ -486,6 +542,10 @@ public class Bubble implements BubbleViewProvider { return (mMetadataShortcutId != null && !mMetadataShortcutId.isEmpty()); } + BubbleTransitions.BubbleTransition getPreparingTransition() { + return mPreparingTransition; + } + /** * Call this to clean up the task for the bubble. Ensure this is always called when done with * the bubble. @@ -556,6 +616,13 @@ public class Bubble implements BubbleViewProvider { } /** + * Sets the current bubble-transition that is coordinating a change in this bubble. + */ + void setPreparingTransition(BubbleTransitions.BubbleTransition transit) { + mPreparingTransition = transit; + } + + /** * Sets whether this bubble is considered text changed. This method is purely for * testing. */ diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java index 4f9028e8aaf3..d0f912ac2142 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java @@ -110,6 +110,7 @@ import com.android.wm.shell.onehanded.OneHandedController; import com.android.wm.shell.onehanded.OneHandedTransitionCallback; import com.android.wm.shell.shared.annotations.ShellBackgroundThread; import com.android.wm.shell.shared.annotations.ShellMainThread; +import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper; import com.android.wm.shell.shared.bubbles.BubbleBarLocation; import com.android.wm.shell.shared.bubbles.BubbleBarUpdate; import com.android.wm.shell.sysui.ConfigurationChangeListener; @@ -287,6 +288,8 @@ public class BubbleController implements ConfigurationChangeListener, /** Used to send updates to the views from {@link #mBubbleDataListener}. */ private BubbleViewCallback mBubbleViewCallback; + private final BubbleTransitions mBubbleTransitions; + public BubbleController(Context context, ShellInit shellInit, ShellCommandHandler shellCommandHandler, @@ -350,12 +353,16 @@ public class BubbleController implements ConfigurationChangeListener, context.getResources().getDimensionPixelSize( com.android.internal.R.dimen.importance_ring_stroke_width)); mDisplayController = displayController; + final TaskViewTransitions tvTransitions; if (TaskViewTransitions.useRepo()) { - mTaskViewController = new TaskViewTransitions(transitions, taskViewRepository, - organizer, syncQueue); + tvTransitions = new TaskViewTransitions(transitions, taskViewRepository, organizer, + syncQueue); } else { - mTaskViewController = taskViewTransitions; + tvTransitions = taskViewTransitions; } + mTaskViewController = tvTransitions; + mBubbleTransitions = new BubbleTransitions(transitions, organizer, taskViewRepository, data, + tvTransitions, context); mTransitions = transitions; mOneHandedOptional = oneHandedOptional; mDragAndDropController = dragAndDropController; @@ -1456,7 +1463,19 @@ public class BubbleController implements ConfigurationChangeListener, * @param taskInfo the task. */ public void expandStackAndSelectBubble(ActivityManager.RunningTaskInfo taskInfo) { - // TODO(384976265): Not implemented yet + if (!BubbleAnythingFlagHelper.enableBubbleToFullscreen()) return; + Bubble b = mBubbleData.getOrCreateBubble(taskInfo); // Removes from overflow + ProtoLog.v(WM_SHELL_BUBBLES, "expandStackAndSelectBubble - intent=%s", taskInfo.taskId); + if (b.isInflated()) { + mBubbleData.setSelectedBubbleAndExpandStack(b); + } else { + b.enable(Notification.BubbleMetadata.FLAG_AUTO_EXPAND_BUBBLE); + // Lazy init stack view when a bubble is created + ensureBubbleViewsAndWindowCreated(); + mBubbleTransitions.startConvertToBubble(b, taskInfo, mExpandedViewManager, + mBubbleTaskViewFactory, mBubblePositioner, mLogger, mStackView, mLayerView, + mBubbleIconFactory, mInflateSynchronously); + } } /** @@ -2261,9 +2280,16 @@ public class BubbleController implements ConfigurationChangeListener, private void showExpandedViewForBubbleBar() { BubbleViewProvider selectedBubble = mBubbleData.getSelectedBubble(); - if (selectedBubble != null && mLayerView != null) { - mLayerView.showExpandedView(selectedBubble); + if (selectedBubble == null) return; + if (selectedBubble instanceof Bubble) { + final Bubble bubble = (Bubble) selectedBubble; + if (bubble.getPreparingTransition() != null) { + bubble.getPreparingTransition().continueExpand(); + return; + } } + if (mLayerView == null) return; + mLayerView.showExpandedView(selectedBubble); } private void collapseExpandedViewForBubbleBar() { diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleData.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleData.java index 74302094a296..96d0f6d5654e 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleData.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleData.java @@ -22,6 +22,7 @@ import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES; import android.annotation.NonNull; import android.app.PendingIntent; +import android.app.TaskInfo; import android.content.Context; import android.content.Intent; import android.content.LocusId; @@ -470,6 +471,17 @@ public class BubbleData { return bubbleToReturn; } + Bubble getOrCreateBubble(TaskInfo taskInfo) { + UserHandle user = UserHandle.of(mCurrentUserId); + String bubbleKey = Bubble.getAppBubbleKeyForTask(taskInfo); + Bubble bubbleToReturn = findAndRemoveBubbleFromOverflow(bubbleKey); + if (bubbleToReturn == null) { + bubbleToReturn = Bubble.createTaskBubble(taskInfo, user, null, mMainExecutor, + mBgExecutor); + } + return bubbleToReturn; + } + @Nullable private Bubble findAndRemoveBubbleFromOverflow(String key) { Bubble bubbleToReturn = getBubbleInStackWithKey(key); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java index 89c038b4a26b..ae84f449c0e4 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTaskViewHelper.java @@ -109,7 +109,9 @@ public class BubbleTaskViewHelper { MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS); final boolean isShortcutBubble = (mBubble.hasMetadataShortcutId() || (mBubble.getShortcutInfo() != null && Flags.enableBubbleAnything())); - if (mBubble.isAppBubble()) { + if (mBubble.getPreparingTransition() != null) { + mBubble.getPreparingTransition().surfaceCreated(); + } else if (mBubble.isAppBubble()) { Context context = mContext.createContextAsUser( mBubble.getUser(), Context.CONTEXT_RESTRICTED); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTransitions.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTransitions.java new file mode 100644 index 000000000000..e37844f53b11 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleTransitions.java @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2025 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.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW; +import static android.view.WindowManager.TRANSIT_CHANGE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.ActivityManager; +import android.app.TaskInfo; +import android.content.Context; +import android.graphics.Rect; +import android.os.IBinder; +import android.util.Slog; +import android.view.SurfaceControl; +import android.view.SurfaceView; +import android.window.TransitionInfo; +import android.window.TransitionRequestInfo; +import android.window.WindowContainerTransaction; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.launcher3.icons.BubbleIconFactory; +import com.android.wm.shell.ShellTaskOrganizer; +import com.android.wm.shell.bubbles.bar.BubbleBarExpandedView; +import com.android.wm.shell.bubbles.bar.BubbleBarLayerView; +import com.android.wm.shell.taskview.TaskView; +import com.android.wm.shell.taskview.TaskViewRepository; +import com.android.wm.shell.taskview.TaskViewTaskController; +import com.android.wm.shell.taskview.TaskViewTransitions; +import com.android.wm.shell.transition.Transitions; + +import java.util.concurrent.Executor; + +/** + * Implements transition coordination for bubble operations. + */ +public class BubbleTransitions { + private static final String TAG = "BubbleTransitions"; + + @NonNull final Transitions mTransitions; + @NonNull final ShellTaskOrganizer mTaskOrganizer; + @NonNull final TaskViewRepository mRepository; + @NonNull final Executor mMainExecutor; + @NonNull final BubbleData mBubbleData; + @NonNull final TaskViewTransitions mTaskViewTransitions; + @NonNull final Context mContext; + + BubbleTransitions(@NonNull Transitions transitions, @NonNull ShellTaskOrganizer organizer, + @NonNull TaskViewRepository repository, @NonNull BubbleData bubbleData, + @NonNull TaskViewTransitions taskViewTransitions, Context context) { + mTransitions = transitions; + mTaskOrganizer = organizer; + mRepository = repository; + mMainExecutor = transitions.getMainExecutor(); + mBubbleData = bubbleData; + mTaskViewTransitions = taskViewTransitions; + mContext = context; + } + + /** + * Starts a convert-to-bubble transition. + * + * @see ConvertToBubble + */ + public BubbleTransition startConvertToBubble(Bubble bubble, TaskInfo taskInfo, + BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory, + BubblePositioner positioner, BubbleLogger logger, BubbleStackView stackView, + BubbleBarLayerView layerView, BubbleIconFactory iconFactory, + boolean inflateSync) { + ConvertToBubble convert = new ConvertToBubble(bubble, taskInfo, mContext, + expandedViewManager, factory, positioner, logger, stackView, layerView, iconFactory, + inflateSync); + return convert; + } + + /** + * Interface to a bubble-specific transition. Bubble transitions have a multi-step lifecycle + * in order to coordinate with the bubble view logic. These steps are communicated on this + * interface. + */ + interface BubbleTransition { + default void surfaceCreated() {} + default void continueExpand() {} + void skip(); + } + + /** + * BubbleTransition that coordinates the process of a non-bubble task becoming a bubble. The + * steps are as follows: + * + * 1. Start inflating the bubble view + * 2. Once inflated (but not-yet visible), tell WM to do the shell-transition. + * 3. Transition becomes ready, so notify Launcher + * 4. Launcher responds with showExpandedView which calls continueExpand() to make view visible + * 5. Surface is created which kicks off actual animation + * + * So, constructor -> onInflated -> startAnimation -> continueExpand -> surfaceCreated. + * + * continueExpand and surfaceCreated are set-up to happen in either order, though, to support + * UX/timing adjustments. + */ + @VisibleForTesting + class ConvertToBubble implements Transitions.TransitionHandler, BubbleTransition { + final BubbleBarLayerView mLayerView; + Bubble mBubble; + IBinder mTransition; + Transitions.TransitionFinishCallback mFinishCb; + WindowContainerTransaction mFinishWct = null; + final Rect mStartBounds = new Rect(); + SurfaceControl mSnapshot = null; + TaskInfo mTaskInfo; + boolean mFinishedExpand = false; + BubbleViewProvider mPriorBubble = null; + + private SurfaceControl.Transaction mFinishT; + private SurfaceControl mTaskLeash; + + ConvertToBubble(Bubble bubble, TaskInfo taskInfo, Context context, + BubbleExpandedViewManager expandedViewManager, BubbleTaskViewFactory factory, + BubblePositioner positioner, BubbleLogger logger, BubbleStackView stackView, + BubbleBarLayerView layerView, BubbleIconFactory iconFactory, boolean inflateSync) { + mBubble = bubble; + mTaskInfo = taskInfo; + mLayerView = layerView; + mBubble.setInflateSynchronously(inflateSync); + mBubble.setPreparingTransition(this); + mBubble.inflate( + this::onInflated, + context, + expandedViewManager, + factory, + positioner, + logger, + stackView, + layerView, + iconFactory, + false /* skipInflation */); + } + + @VisibleForTesting + void onInflated(Bubble b) { + if (b != mBubble) { + throw new IllegalArgumentException("inflate callback doesn't match bubble"); + } + final Rect launchBounds = new Rect(); + mLayerView.getExpandedViewRestBounds(launchBounds); + WindowContainerTransaction wct = new WindowContainerTransaction(); + if (mTaskInfo.getWindowingMode() == WINDOWING_MODE_MULTI_WINDOW) { + if (mTaskInfo.getParentTaskId() != INVALID_TASK_ID) { + wct.reparent(mTaskInfo.token, null, true); + } + } + + wct.setAlwaysOnTop(mTaskInfo.token, true); + wct.setWindowingMode(mTaskInfo.token, WINDOWING_MODE_MULTI_WINDOW); + wct.setBounds(mTaskInfo.token, launchBounds); + + final TaskView tv = b.getTaskView(); + tv.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT); + final TaskViewRepository.TaskViewState state = mRepository.byTaskView( + tv.getController()); + if (state != null) { + state.mVisible = true; + } + mTaskViewTransitions.enqueueExternal(tv.getController(), () -> { + mTransition = mTransitions.startTransition(TRANSIT_CHANGE, wct, this); + return mTransition; + }); + } + + @Override + public void skip() { + mBubble.setPreparingTransition(null); + mFinishCb.onTransitionFinished(mFinishWct); + mFinishCb = null; + } + + @Override + public WindowContainerTransaction handleRequest(@NonNull IBinder transition, + @Nullable TransitionRequestInfo request) { + return null; + } + + @Override + public void mergeAnimation(@NonNull IBinder transition, @NonNull TransitionInfo info, + @NonNull SurfaceControl.Transaction t, @NonNull IBinder mergeTarget, + @NonNull Transitions.TransitionFinishCallback finishCallback) { + } + + @Override + public void onTransitionConsumed(@NonNull IBinder transition, boolean aborted, + @NonNull SurfaceControl.Transaction finishTransaction) { + if (!aborted) return; + mTransition = null; + mTaskViewTransitions.onExternalDone(transition); + } + + @Override + public boolean startAnimation(@NonNull IBinder transition, + @NonNull TransitionInfo info, + @NonNull SurfaceControl.Transaction startTransaction, + @NonNull SurfaceControl.Transaction finishTransaction, + @NonNull Transitions.TransitionFinishCallback finishCallback) { + if (mTransition != transition) return false; + boolean found = false; + for (int i = 0; i < info.getChanges().size(); ++i) { + final TransitionInfo.Change chg = info.getChanges().get(i); + if (chg.getTaskInfo() == null) continue; + if (chg.getMode() != TRANSIT_CHANGE) continue; + if (!mTaskInfo.token.equals(chg.getTaskInfo().token)) continue; + mStartBounds.set(chg.getStartAbsBounds()); + // Converting a task into taskview, so treat as "new" + mFinishWct = new WindowContainerTransaction(); + mTaskInfo = chg.getTaskInfo(); + mFinishT = finishTransaction; + mTaskLeash = chg.getLeash(); + found = true; + mSnapshot = chg.getSnapshot(); + break; + } + if (!found) { + Slog.w(TAG, "Expected a TaskView conversion in this transition but didn't get " + + "one, cleaning up the task view"); + mBubble.getTaskView().getController().setTaskNotFound(); + mTaskViewTransitions.onExternalDone(transition); + return false; + } + mFinishCb = finishCallback; + + // Now update state (and talk to launcher) in parallel with snapshot stuff + mBubbleData.notificationEntryUpdated(mBubble, /* suppressFlyout= */ true, + /* showInShade= */ false); + + startTransaction.show(mSnapshot); + // Move snapshot to root so that it remains visible while task is moved to taskview + startTransaction.reparent(mSnapshot, info.getRoot(0).getLeash()); + startTransaction.setPosition(mSnapshot, + mStartBounds.left - info.getRoot(0).getOffset().x, + mStartBounds.top - info.getRoot(0).getOffset().y); + startTransaction.setLayer(mSnapshot, Integer.MAX_VALUE); + startTransaction.apply(); + + mTaskViewTransitions.onExternalDone(transition); + return true; + } + + @Override + public void continueExpand() { + mFinishedExpand = true; + final boolean animate = mLayerView.canExpandView(mBubble); + if (animate) { + mPriorBubble = mLayerView.prepareConvertedView(mBubble); + } + if (mPriorBubble != null) { + // TODO: an animation. For now though, just remove it. + final BubbleBarExpandedView priorView = mPriorBubble.getBubbleBarExpandedView(); + mLayerView.removeView(priorView); + mPriorBubble = null; + } + if (!animate || mBubble.getTaskView().getSurfaceControl() != null) { + playAnimation(animate); + } + } + + @Override + public void surfaceCreated() { + mMainExecutor.execute(() -> { + final TaskViewTaskController tvc = mBubble.getTaskView().getController(); + final TaskViewRepository.TaskViewState state = mRepository.byTaskView(tvc); + if (state == null) return; + state.mVisible = true; + if (mFinishedExpand) { + playAnimation(true /* animate */); + } + }); + } + + private void playAnimation(boolean animate) { + final TaskViewTaskController tv = mBubble.getTaskView().getController(); + final SurfaceControl.Transaction startT = new SurfaceControl.Transaction(); + mTaskViewTransitions.prepareOpenAnimation(tv, true /* new */, startT, mFinishT, + (ActivityManager.RunningTaskInfo) mTaskInfo, mTaskLeash, mFinishWct); + + if (mFinishWct.isEmpty()) { + mFinishWct = null; + } + + // Preparation is complete. + mBubble.setPreparingTransition(null); + + if (animate) { + mLayerView.animateConvert(startT, mStartBounds, mSnapshot, mTaskLeash, () -> { + mFinishCb.onTransitionFinished(mFinishWct); + mFinishCb = null; + }); + } else { + startT.apply(); + mFinishCb.onTransitionFinished(mFinishWct); + mFinishCb = null; + } + } + } +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java index de6d1f6c8852..52f20646fb4a 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java @@ -36,17 +36,21 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; +import android.annotation.NonNull; import android.content.Context; import android.graphics.Point; import android.graphics.Rect; import android.util.Log; import android.util.Size; +import android.view.SurfaceControl; import android.widget.FrameLayout; import androidx.annotation.Nullable; import com.android.app.animation.Interpolators; import com.android.wm.shell.R; +import com.android.wm.shell.animation.SizeChangeAnimation; +import com.android.wm.shell.bubbles.Bubble; import com.android.wm.shell.bubbles.BubbleOverflow; import com.android.wm.shell.bubbles.BubblePositioner; import com.android.wm.shell.bubbles.BubbleViewProvider; @@ -571,6 +575,49 @@ public class BubbleBarAnimationHelper { } /** + * Animates converting of a non-bubble task into an expanded bubble view. + */ + public void animateConvert(BubbleViewProvider expandedBubble, + @NonNull SurfaceControl.Transaction startT, + @NonNull Rect origBounds, + @NonNull SurfaceControl snapshot, + @NonNull SurfaceControl taskLeash, + @Nullable Runnable afterAnimation) { + mExpandedBubble = expandedBubble; + final BubbleBarExpandedView bbev = getExpandedView(); + if (bbev == null) { + return; + } + + bbev.setTaskViewAlpha(1f); + SurfaceControl tvSf = ((Bubble) mExpandedBubble).getTaskView().getSurfaceControl(); + + final Size size = getExpandedViewSize(); + Point position = getExpandedViewRestPosition(size); + + final SizeChangeAnimation sca = + new SizeChangeAnimation( + new Rect(origBounds.left - position.x, origBounds.top - position.y, + origBounds.right - position.x, origBounds.bottom - position.y), + new Rect(0, 0, size.getWidth(), size.getHeight())); + sca.initialize(bbev, taskLeash, snapshot, startT); + + Animator a = sca.buildViewAnimator(bbev, tvSf, snapshot, /* onFinish */ (va) -> { + updateExpandedView(bbev); + snapshot.release(); + bbev.setSurfaceZOrderedOnTop(false); + bbev.setAnimating(false); + if (afterAnimation != null) { + afterAnimation.run(); + } + }); + + bbev.setSurfaceZOrderedOnTop(true); + a.setDuration(EXPANDED_VIEW_ANIMATE_TO_REST_DURATION); + a.start(); + } + + /** * Cancel current animations */ public void cancelAnimations() { @@ -627,6 +674,13 @@ public class BubbleBarAnimationHelper { bbev.maybeShowOverflow(); } + void getExpandedViewRestBounds(Rect out) { + final int width = mPositioner.getExpandedViewWidthForBubbleBar(false /* overflow */); + final int height = mPositioner.getExpandedViewHeightForBubbleBar(false /* overflow */); + Point position = getExpandedViewRestPosition(new Size(width, height)); + out.set(position.x, position.y, position.x + width, position.y + height); + } + private Point getExpandedViewRestPosition(Size size) { final int padding = mPositioner.getBubbleBarExpandedViewPadding(); Point point = new Point(); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java index eaa0bd250fc4..91dcbdf5f117 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java @@ -28,6 +28,7 @@ import android.graphics.Rect; import android.graphics.Region; import android.graphics.drawable.ColorDrawable; import android.view.Gravity; +import android.view.SurfaceControl; import android.view.TouchDelegate; import android.view.View; import android.view.ViewTreeObserver; @@ -174,14 +175,34 @@ public class BubbleBarLayerView extends FrameLayout /** Shows the expanded view of the provided bubble. */ public void showExpandedView(BubbleViewProvider b) { - BubbleBarExpandedView expandedView = b.getBubbleBarExpandedView(); - if (expandedView == null) { - return; - } + if (!canExpandView(b)) return; + animateExpand(prepareExpandedView(b)); + } + + /** + * @return whether it's possible to expand {@param b} right now. This is {@code false} if + * the bubble has no view or if the bubble is already showing. + */ + public boolean canExpandView(BubbleViewProvider b) { + if (b.getBubbleBarExpandedView() == null) return false; if (mExpandedBubble != null && mIsExpanded && b.getKey().equals(mExpandedBubble.getKey())) { - // Already showing this bubble, skip animating - return; + // Already showing this bubble so can't expand it. + return false; + } + return true; + } + + /** + * Prepares the expanded view of the provided bubble to be shown. This includes removing any + * stale content and cancelling any related animations. + * + * @return previous open bubble if there was one. + */ + private BubbleViewProvider prepareExpandedView(BubbleViewProvider b) { + if (!canExpandView(b)) { + throw new IllegalStateException("Can't prepare expand. Check canExpandView(b) first."); } + BubbleBarExpandedView expandedView = b.getBubbleBarExpandedView(); BubbleViewProvider previousBubble = null; if (mExpandedBubble != null && !b.getKey().equals(mExpandedBubble.getKey())) { if (mIsExpanded && mExpandedBubble.getBubbleBarExpandedView() != null) { @@ -251,7 +272,20 @@ public class BubbleBarLayerView extends FrameLayout mIsExpanded = true; mBubbleController.getSysuiProxy().onStackExpandChanged(true); + showScrim(true); + return previousBubble; + } + /** + * Performs an animation to open a bubble with content that is not already visible. + * + * @param previousBubble If non-null, this is a bubble that is already showing before the new + * bubble is expanded. + */ + public void animateExpand(BubbleViewProvider previousBubble) { + if (!mIsExpanded || mExpandedBubble == null) { + throw new IllegalStateException("Can't animateExpand without expnaded state"); + } final Runnable afterAnimation = () -> { if (mExpandedView == null) return; // Touch delegate for the menu @@ -274,8 +308,49 @@ public class BubbleBarLayerView extends FrameLayout } else { mAnimationHelper.animateExpansion(mExpandedBubble, afterAnimation); } + } - showScrim(true); + /** + * Like {@link #prepareExpandedView} but also makes the current expanded bubble visible + * immediately so it gets a surface that can be animated. Since the surface may not be ready + * yet, this keeps the TaskView alpha=0. + */ + public BubbleViewProvider prepareConvertedView(BubbleViewProvider b) { + final BubbleViewProvider prior = prepareExpandedView(b); + + final BubbleBarExpandedView bbev = mExpandedBubble.getBubbleBarExpandedView(); + if (bbev != null) { + updateExpandedView(); + bbev.setAnimating(true); + bbev.setContentVisibility(true); + bbev.setSurfaceZOrderedOnTop(true); + bbev.setTaskViewAlpha(0.f); + bbev.setVisibility(VISIBLE); + } + + return prior; + } + + /** + * Starts and animates a conversion-from transition. + * + * @param startT A transaction with first-frame work. this *will* be applied here! + */ + public void animateConvert(@NonNull SurfaceControl.Transaction startT, + @NonNull Rect startBounds, @NonNull SurfaceControl snapshot, SurfaceControl taskLeash, + Runnable animFinish) { + if (!mIsExpanded || mExpandedBubble == null) { + throw new IllegalStateException("Can't animateExpand without expanded state"); + } + mAnimationHelper.animateConvert(mExpandedBubble, startT, startBounds, snapshot, taskLeash, + animFinish); + } + + /** + * Populates {@param out} with the rest bounds of an expanded bubble. + */ + public void getExpandedViewRestBounds(Rect out) { + mAnimationHelper.getExpandedViewRestBounds(out); } /** Removes the given {@code bubble}. */ diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskView.java index 0445add9cba9..13d87eda085b 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskView.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskView.java @@ -92,6 +92,10 @@ public class TaskView extends SurfaceView implements SurfaceHolder.Callback, getHolder().addCallback(this); } + public TaskViewTaskController getController() { + return mTaskViewTaskController; + } + /** * Launch a new activity. * diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTaskController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTaskController.java index d19a7eac6ad2..aef75e2dc99e 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTaskController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTaskController.java @@ -448,7 +448,7 @@ public class TaskViewTaskController implements ShellTaskOrganizer.TaskListener { * have the pending info, we'll do it when we receive it in * {@link #onTaskAppeared(ActivityManager.RunningTaskInfo, SurfaceControl)}. */ - void setTaskNotFound() { + public void setTaskNotFound() { mTaskNotFound = true; if (mPendingInfo != null) { cleanUpPendingTask(); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java index 6c90a9060523..1eaae7ec83d9 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/taskview/TaskViewTransitions.java @@ -20,6 +20,7 @@ import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW; import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED; import static android.view.WindowManager.TRANSIT_CHANGE; import static android.view.WindowManager.TRANSIT_CLOSE; +import static android.view.WindowManager.TRANSIT_NONE; import static android.view.WindowManager.TRANSIT_OPEN; import static android.view.WindowManager.TRANSIT_TO_BACK; import static android.view.WindowManager.TRANSIT_TO_FRONT; @@ -66,7 +67,7 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV static final String TAG = "TaskViewTransitions"; /** - * Map of {@link TaskViewTaskController} to {@link TaskViewRequestedState}. + * Map of {@link TaskViewTaskController} to {@link TaskViewRepository.TaskViewState}. * <p> * {@link TaskView} keeps a reference to the {@link TaskViewTaskController} instance and * manages its lifecycle. @@ -95,6 +96,7 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV final @WindowManager.TransitionType int mType; final WindowContainerTransaction mWct; final @NonNull TaskViewTaskController mTaskView; + ExternalTransition mExternalTransition; IBinder mClaimed; /** @@ -182,6 +184,32 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV } /** + * Starts or queues an "external" runnable into the pending queue. This means it will run + * in order relative to the local transitions. + * + * The external operation *must* call {@link #onExternalDone} once it has finished. + * + * In practice, the external is usually another transition on a different handler. + */ + public void enqueueExternal(@NonNull TaskViewTaskController taskView, ExternalTransition ext) { + final PendingTransition pending = new PendingTransition( + TRANSIT_NONE, null /* wct */, taskView, null /* cookie */); + pending.mExternalTransition = ext; + mPending.add(pending); + startNextTransition(); + } + + /** + * An external transition run in this "queue" is required to call this once it becomes ready. + */ + public void onExternalDone(IBinder key) { + final PendingTransition pending = findPending(key); + if (pending == null) return; + mPending.remove(pending); + startNextTransition(); + } + + /** * Looks through the pending transitions for a opening transaction that matches the provided * `taskView`. * @@ -191,6 +219,7 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV PendingTransition findPendingOpeningTransition(TaskViewTaskController taskView) { for (int i = mPending.size() - 1; i >= 0; --i) { if (mPending.get(i).mTaskView != taskView) continue; + if (mPending.get(i).mExternalTransition != null) continue; if (TransitionUtil.isOpeningType(mPending.get(i).mType)) { return mPending.get(i); } @@ -207,6 +236,7 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV PendingTransition findPending(TaskViewTaskController taskView, int type) { for (int i = mPending.size() - 1; i >= 0; --i) { if (mPending.get(i).mTaskView != taskView) continue; + if (mPending.get(i).mExternalTransition != null) continue; if (mPending.get(i).mType == type) { return mPending.get(i); } @@ -518,7 +548,11 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV // Wait for this to start animating. return; } - pending.mClaimed = mTransitions.startTransition(pending.mType, pending.mWct, this); + if (pending.mExternalTransition != null) { + pending.mClaimed = pending.mExternalTransition.start(); + } else { + pending.mClaimed = mTransitions.startTransition(pending.mType, pending.mWct, this); + } } @Override @@ -641,7 +675,7 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV } @VisibleForTesting - void prepareOpenAnimation(TaskViewTaskController taskView, + public void prepareOpenAnimation(TaskViewTaskController taskView, final boolean newTask, SurfaceControl.Transaction startTransaction, SurfaceControl.Transaction finishTransaction, @@ -695,4 +729,10 @@ public class TaskViewTransitions implements Transitions.TransitionHandler, TaskV taskView.notifyAppeared(newTask); } + + /** Interface for running an external transition in this object's pending queue. */ + public interface ExternalTransition { + /** Starts a transition and returns an identifying key for lookup. */ + IBinder start(); + } } diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleTransitionsTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleTransitionsTest.java new file mode 100644 index 000000000000..b4f514acf2dd --- /dev/null +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleTransitionsTest.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2025 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.view.WindowManager.TRANSIT_CHANGE; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.ActivityManager; +import android.os.IBinder; +import android.view.SurfaceControl; +import android.view.ViewRootImpl; +import android.window.IWindowContainerToken; +import android.window.TransitionInfo; +import android.window.WindowContainerToken; + +import androidx.test.filters.SmallTest; + +import com.android.launcher3.icons.BubbleIconFactory; +import com.android.wm.shell.ShellTaskOrganizer; +import com.android.wm.shell.ShellTestCase; +import com.android.wm.shell.TestSyncExecutor; +import com.android.wm.shell.bubbles.bar.BubbleBarExpandedView; +import com.android.wm.shell.bubbles.bar.BubbleBarLayerView; +import com.android.wm.shell.common.ShellExecutor; +import com.android.wm.shell.common.SyncTransactionQueue; +import com.android.wm.shell.taskview.TaskView; +import com.android.wm.shell.taskview.TaskViewRepository; +import com.android.wm.shell.taskview.TaskViewTaskController; +import com.android.wm.shell.taskview.TaskViewTransitions; +import com.android.wm.shell.transition.Transitions; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Tests of {@link BubbleTransitions}. + */ +@SmallTest +public class BubbleTransitionsTest extends ShellTestCase { + @Mock + private BubbleData mBubbleData; + @Mock + private Bubble mBubble; + @Mock + private Transitions mTransitions; + @Mock + private SyncTransactionQueue mSyncQueue; + @Mock + private BubbleExpandedViewManager mExpandedViewManager; + @Mock + private BubblePositioner mBubblePositioner; + @Mock + private BubbleLogger mBubbleLogger; + @Mock + private BubbleStackView mStackView; + @Mock + private BubbleBarLayerView mLayerView; + @Mock + private BubbleIconFactory mIconFactory; + + @Mock private ShellTaskOrganizer mTaskOrganizer; + private TaskViewTransitions mTaskViewTransitions; + private TaskViewRepository mRepository; + private BubbleTransitions mBubbleTransitions; + private BubbleTaskViewFactory mTaskViewFactory; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mRepository = new TaskViewRepository(); + ShellExecutor syncExecutor = new TestSyncExecutor(); + + when(mTransitions.getMainExecutor()).thenReturn(syncExecutor); + when(mTransitions.isRegistered()).thenReturn(true); + mTaskViewTransitions = new TaskViewTransitions(mTransitions, mRepository, mTaskOrganizer, + mSyncQueue); + mBubbleTransitions = new BubbleTransitions(mTransitions, mTaskOrganizer, mRepository, + mBubbleData, mTaskViewTransitions, mContext); + mTaskViewFactory = () -> { + TaskViewTaskController taskViewTaskController = new TaskViewTaskController( + mContext, mTaskOrganizer, mTaskViewTransitions, mSyncQueue); + TaskView taskView = new TaskView(mContext, mTaskViewTransitions, + taskViewTaskController); + return new BubbleTaskView(taskView, syncExecutor); + }; + final BubbleBarExpandedView bbev = mock(BubbleBarExpandedView.class); + final ViewRootImpl vri = mock(ViewRootImpl.class); + when(bbev.getViewRootImpl()).thenReturn(vri); + when(mBubble.getBubbleBarExpandedView()).thenReturn(bbev); + } + + private ActivityManager.RunningTaskInfo setupBubble() { + ActivityManager.RunningTaskInfo taskInfo = new ActivityManager.RunningTaskInfo(); + final IWindowContainerToken itoken = mock(IWindowContainerToken.class); + final IBinder asBinder = mock(IBinder.class); + when(itoken.asBinder()).thenReturn(asBinder); + WindowContainerToken token = new WindowContainerToken(itoken); + taskInfo.token = token; + final TaskView tv = mock(TaskView.class); + final TaskViewTaskController tvtc = mock(TaskViewTaskController.class); + when(tvtc.getTaskInfo()).thenReturn(taskInfo); + when(tv.getController()).thenReturn(tvtc); + when(mBubble.getTaskView()).thenReturn(tv); + mRepository.add(tvtc); + return taskInfo; + } + + @Test + public void testConvertToBubble() { + // Basic walk-through of convert-to-bubble transition stages + ActivityManager.RunningTaskInfo taskInfo = setupBubble(); + final BubbleTransitions.BubbleTransition bt = mBubbleTransitions.startConvertToBubble( + mBubble, taskInfo, mExpandedViewManager, mTaskViewFactory, mBubblePositioner, + mBubbleLogger, mStackView, mLayerView, mIconFactory, false); + final BubbleTransitions.ConvertToBubble ctb = (BubbleTransitions.ConvertToBubble) bt; + ctb.onInflated(mBubble); + when(mLayerView.canExpandView(any())).thenReturn(true); + verify(mTransitions).startTransition(anyInt(), any(), eq(ctb)); + verify(mBubble).setPreparingTransition(eq(bt)); + // Ensure we are communicating with the taskviewtransitions queue + assertTrue(mTaskViewTransitions.hasPending()); + + final TransitionInfo info = new TransitionInfo(TRANSIT_CHANGE, 0); + final TransitionInfo.Change chg = new TransitionInfo.Change(taskInfo.token, + mock(SurfaceControl.class)); + chg.setTaskInfo(taskInfo); + chg.setMode(TRANSIT_CHANGE); + info.addChange(chg); + info.addRoot(new TransitionInfo.Root(0, mock(SurfaceControl.class), 0, 0)); + SurfaceControl.Transaction startT = mock(SurfaceControl.Transaction.class); + SurfaceControl.Transaction finishT = mock(SurfaceControl.Transaction.class); + final boolean[] finishCalled = new boolean[]{false}; + Transitions.TransitionFinishCallback finishCb = wct -> { + assertFalse(finishCalled[0]); + finishCalled[0] = true; + }; + ctb.startAnimation(ctb.mTransition, info, startT, finishT, finishCb); + assertFalse(mTaskViewTransitions.hasPending()); + + verify(mBubbleData).notificationEntryUpdated(eq(mBubble), anyBoolean(), anyBoolean()); + ctb.continueExpand(); + + clearInvocations(mBubble); + verify(mBubble, never()).setPreparingTransition(any()); + + ctb.surfaceCreated(); + verify(mBubble).setPreparingTransition(isNull()); + ArgumentCaptor<Runnable> animCb = ArgumentCaptor.forClass(Runnable.class); + verify(mLayerView).animateConvert(any(), any(), any(), any(), animCb.capture()); + assertFalse(finishCalled[0]); + animCb.getValue().run(); + assertTrue(finishCalled[0]); + } +} |