Merge "Adding MSDL history to Launcher's dump information." into main
diff --git a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
index e05178d..f9e7cf0 100644
--- a/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/NavbarButtonsViewController.java
@@ -15,6 +15,7 @@
  */
 package com.android.launcher3.taskbar;
 
+import static android.view.KeyEvent.ACTION_UP;
 import static android.view.View.AccessibilityDelegate;
 import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
 import static android.view.ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION;
@@ -72,7 +73,6 @@
 import android.graphics.drawable.RotateDrawable;
 import android.inputmethodservice.InputMethodService;
 import android.os.Handler;
-import android.os.SystemClock;
 import android.util.Property;
 import android.view.Gravity;
 import android.view.KeyEvent;
@@ -864,17 +864,12 @@
             TaskbarNavButtonController navButtonController) {
         buttonView.setOnTouchListener((v, event) -> {
             if (event.getAction() == MotionEvent.ACTION_MOVE) return false;
-            long time = SystemClock.uptimeMillis();
-            int action = event.getAction();
-            KeyEvent keyEvent = new KeyEvent(time, time,
-                    action == MotionEvent.ACTION_DOWN ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP,
-                    KeyEvent.KEYCODE_BACK, 0);
-            if (event.getAction() == MotionEvent.ACTION_CANCEL) {
-                keyEvent.cancel();
-            }
-            navButtonController.executeBack(keyEvent);
-
-            if (action == MotionEvent.ACTION_UP) {
+            int motionEventAction = event.getAction();
+            int keyEventAction = motionEventAction == MotionEvent.ACTION_DOWN
+                    ? KeyEvent.ACTION_DOWN : ACTION_UP;
+            boolean isCancelled = event.getAction() == MotionEvent.ACTION_CANCEL;
+            navButtonController.sendBackKeyEvent(keyEventAction, isCancelled);
+            if (motionEventAction == MotionEvent.ACTION_UP) {
                 buttonView.performClick();
             }
             return false;
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarNavButtonController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarNavButtonController.java
index d4764c7..4881836 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarNavButtonController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarNavButtonController.java
@@ -16,7 +16,8 @@
 
 package com.android.launcher3.taskbar;
 
-import static android.view.MotionEvent.ACTION_UP;
+import static android.view.KeyEvent.ACTION_DOWN;
+import static android.view.KeyEvent.ACTION_UP;
 
 import static com.android.internal.app.AssistUtils.INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS;
 import static com.android.internal.app.AssistUtils.INVOCATION_TYPE_KEY;
@@ -38,6 +39,7 @@
 import android.content.Context;
 import android.os.Bundle;
 import android.os.Handler;
+import android.os.SystemClock;
 import android.util.Log;
 import android.view.HapticFeedbackConstants;
 import android.view.KeyEvent;
@@ -78,6 +80,7 @@
     private long mLastScreenPinLongPress;
     private boolean mScreenPinned;
     private boolean mAssistantLongPressEnabled;
+    private int mLastSentBackAction = ACTION_UP;
 
     @Override
     public void dumpLogs(String prefix, PrintWriter pw) {
@@ -85,6 +88,8 @@
 
         pw.println(prefix + "\tmLastScreenPinLongPress=" + mLastScreenPinLongPress);
         pw.println(prefix + "\tmScreenPinned=" + mScreenPinned);
+        pw.println(prefix + "\tmLastSentBackAction="
+                + KeyEvent.actionToString(mLastSentBackAction));
     }
 
     @Retention(RetentionPolicy.SOURCE)
@@ -141,6 +146,11 @@
         if (buttonType == BUTTON_SPACE) {
             return;
         }
+        if (predictiveBackThreeButtonNav() && mLastSentBackAction == ACTION_DOWN) {
+            Log.i(TAG, "Button click ignored while back button is pressed");
+            // prevent interactions with other buttons while back button is pressed
+            return;
+        }
         // Provide the same haptic feedback that the system offers for virtual keys.
         view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
         switch (buttonType) {
@@ -180,6 +190,13 @@
         if (buttonType == BUTTON_SPACE) {
             return false;
         }
+        if (predictiveBackThreeButtonNav() && mLastSentBackAction == ACTION_DOWN
+                && buttonType != BUTTON_BACK && buttonType != BUTTON_RECENTS) {
+            // prevent interactions with other buttons while back button is pressed (except back
+            // and recents button for screen-unpin action).
+            Log.i(TAG, "Button long click ignored while back button is pressed");
+            return false;
+        }
 
         // Provide the same haptic feedback that the system offers for long press.
         // The haptic feedback from long pressing on the home button is handled by circle to search.
@@ -327,13 +344,27 @@
         mCallbacks.onToggleOverview();
     }
 
-    void executeBack(@Nullable KeyEvent keyEvent) {
+    void sendBackKeyEvent(int action, boolean cancelled) {
+        if (action == mLastSentBackAction) {
+            // There must always be an alternating sequence of ACTION_DOWN and ACTION_UP events
+            return;
+        }
+        long time = SystemClock.uptimeMillis();
+        KeyEvent keyEvent = new KeyEvent(time, time, action, KeyEvent.KEYCODE_BACK, 0);
+        if (cancelled) {
+            keyEvent.cancel();
+        }
+        executeBack(keyEvent);
+    }
+
+    private void executeBack(@Nullable KeyEvent keyEvent) {
         if (keyEvent == null || (keyEvent.getAction() == ACTION_UP && !keyEvent.isCanceled())) {
             logEvent(LAUNCHER_TASKBAR_BACK_BUTTON_TAP);
             mContextualEduStatsManager.updateEduStats(/* isTrackpadGesture= */ false,
                     GestureType.BACK);
         }
         mSystemUiProxy.onBackEvent(keyEvent);
+        mLastSentBackAction = keyEvent != null ? keyEvent.getAction() : ACTION_UP;
     }
 
     private void onImeSwitcherPress() {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
index 8816a6d..3c5d71e 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarView.java
@@ -848,6 +848,13 @@
     }
 
     /**
+     * The max number of icon views the taskbar can have when taskbar overflow is enabled.
+     */
+    int getMaxNumIconViews() {
+        return mMaxNumIcons;
+    }
+
+    /**
      * Returns the all apps button in the taskbar.
      */
     public TaskbarAllAppsButtonContainer getAllAppsButtonContainer() {
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
index bb4f07a..bc5f9a3 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarViewController.java
@@ -351,6 +351,11 @@
         OneShotPreDrawListener.add(mTaskbarView, listener);
     }
 
+    @VisibleForTesting
+    int getMaxNumIconViews() {
+        return mTaskbarView.getMaxNumIconViews();
+    }
+
     public Rect getIconLayoutVisualBounds() {
         return mTaskbarView.getIconLayoutVisualBounds();
     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
index c74fa9b..92fd5e8 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java
@@ -351,14 +351,20 @@
                 .start();
     }
 
-    /** Suppresses or un-suppresses drawing the dot due to an update for this bubble. */
-    public void suppressDotForBubbleUpdate(boolean suppress) {
-        mDotSuppressedForBubbleUpdate = suppress;
-        if (suppress) {
-            setDotScale(0);
-        } else {
-            showDotIfNeeded(/* animate= */ false);
-        }
+    /** Suppresses drawing the dot due to an update for this bubble. */
+    public void suppressDotForBubbleUpdate() {
+        mDotSuppressedForBubbleUpdate = true;
+        setDotScale(0);
+    }
+
+    /**
+     * Unsuppresses the dot after the bubble update finished animating.
+     *
+     * @param animate whether or not to animate the dot back in
+     */
+    public void unsuppressDotForBubbleUpdate(boolean animate) {
+        mDotSuppressedForBubbleUpdate = false;
+        showDotIfNeeded(animate);
     }
 
     boolean hasUnseenContent() {
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
index 6c354f3..5184a9f 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleBarViewAnimator.kt
@@ -486,7 +486,7 @@
         if (flyout != null) {
             bubbleBarFlyoutController.setUpAndShowFlyout(
                 BubbleBarFlyoutMessage(flyout.icon, flyout.title, flyout.message),
-                onInit = { bubbleView.suppressDotForBubbleUpdate(true) },
+                onInit = { bubbleView.suppressDotForBubbleUpdate() },
                 onEnd = {
                     moveToState(AnimatingBubble.State.IN)
                     bubbleStashController.updateTaskbarTouchRegion()
@@ -498,11 +498,12 @@
     }
 
     private fun cancelFlyout() {
-        bubbleBarFlyoutController.cancelFlyout { onFlyoutRemoved() }
+        animatingBubble?.bubbleView?.unsuppressDotForBubbleUpdate(/* animate= */ true)
+        bubbleBarFlyoutController.cancelFlyout { bubbleStashController.updateTaskbarTouchRegion() }
     }
 
     private fun onFlyoutRemoved() {
-        animatingBubble?.bubbleView?.suppressDotForBubbleUpdate(false)
+        animatingBubble?.bubbleView?.unsuppressDotForBubbleUpdate(/* animate= */ false)
         bubbleStashController.updateTaskbarTouchRegion()
     }
 
@@ -573,7 +574,7 @@
         val flyout = bubble?.flyoutMessage
         if (flyout != null) {
             // the flyout is currently expanding and we need to update it with new data
-            bubbleView.suppressDotForBubbleUpdate(true)
+            bubbleView.suppressDotForBubbleUpdate()
             bubbleBarFlyoutController.updateFlyoutWhileExpanding(flyout)
         } else {
             // the flyout is expanding but we don't have new flyout data to update it with,
@@ -588,7 +589,7 @@
         isExpanding: Boolean,
     ) {
         // unsuppress the current bubble because we are about to hide its flyout
-        animatingBubble.bubbleView.suppressDotForBubbleUpdate(false)
+        animatingBubble.bubbleView.unsuppressDotForBubbleUpdate(/* animate= */ false)
         this.animatingBubble = animatingBubble.copy(bubbleView = bubbleView, expand = isExpanding)
 
         // we're currently idle, waiting for the hide animation to start. update the flyout
@@ -601,7 +602,7 @@
         val bubble = bubbleView.bubble as? BubbleBarBubble
         val flyout = bubble?.flyoutMessage
         if (flyout != null) {
-            bubbleView.suppressDotForBubbleUpdate(true)
+            bubbleView.suppressDotForBubbleUpdate()
             bubbleBarFlyoutController.updateFlyoutFullyExpanded(flyout) {
                 bubbleStashController.updateTaskbarTouchRegion()
             }
@@ -616,7 +617,7 @@
         isExpanding: Boolean,
     ) {
         // unsuppress the current bubble because we are about to hide its flyout
-        animatingBubble.bubbleView.suppressDotForBubbleUpdate(false)
+        animatingBubble.bubbleView.unsuppressDotForBubbleUpdate(/* animate= */ false)
         this.animatingBubble = animatingBubble.copy(bubbleView = bubbleView, expand = isExpanding)
 
         // the hide animation already started so it can't be canceled, just post it again
@@ -629,7 +630,7 @@
             // the flyout is collapsing. update it with the new flyout
             if (flyout != null) {
                 moveToState(AnimatingBubble.State.ANIMATING_IN)
-                bubbleView.suppressDotForBubbleUpdate(true)
+                bubbleView.suppressDotForBubbleUpdate()
                 bubbleBarFlyoutController.updateFlyoutWhileCollapsing(flyout) {
                     moveToState(AnimatingBubble.State.IN)
                     bubbleStashController.updateTaskbarTouchRegion()
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index 95e7737..21c4d8c 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -56,9 +56,11 @@
 import static com.android.quickstep.GestureState.STATE_RECENTS_ANIMATION_STARTED;
 import static com.android.quickstep.GestureState.STATE_RECENTS_SCROLLING_FINISHED;
 import static com.android.quickstep.MultiStateCallback.DEBUG_STATES;
+import static com.android.quickstep.TaskViewUtils.extractTargetsAndStates;
 import static com.android.quickstep.util.ActiveGestureErrorDetector.GestureEvent.EXPECTING_TASK_APPEARED;
 import static com.android.quickstep.views.RecentsView.UPDATE_SYSUI_FLAGS_THRESHOLD;
 import static com.android.systemui.shared.system.ActivityManagerWrapper.CLOSE_SYSTEM_WINDOWS_REASON_RECENTS;
+import static com.android.wm.shell.shared.ShellSharedConstants.KEY_EXTRA_SHELL_CAN_HAND_OFF_ANIMATION;
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
@@ -77,6 +79,7 @@
 import android.os.IBinder;
 import android.os.SystemClock;
 import android.util.Log;
+import android.util.Pair;
 import android.view.MotionEvent;
 import android.view.RemoteAnimationTarget;
 import android.view.SurfaceControl;
@@ -90,6 +93,7 @@
 import android.widget.Toast;
 import android.window.DesktopModeFlags;
 import android.window.PictureInPictureSurfaceTransaction;
+import android.window.WindowAnimationState;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -143,6 +147,7 @@
 import com.android.quickstep.views.RecentsViewContainer;
 import com.android.quickstep.views.TaskContainer;
 import com.android.quickstep.views.TaskView;
+import com.android.systemui.animation.TransitionAnimator;
 import com.android.systemui.contextualeducation.GestureType;
 import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.recents.model.ThumbnailData;
@@ -156,6 +161,8 @@
 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus;
 import com.android.wm.shell.shared.startingsurface.SplashScreenExitAnimationUtils;
 
+import kotlin.Unit;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -165,8 +172,6 @@
 import java.util.OptionalInt;
 import java.util.function.Consumer;
 
-import kotlin.Unit;
-
 /**
  * Handles the navigation gestures when Launcher is the default home activity.
  */
@@ -347,6 +352,9 @@
     // Indicates whether the divider is shown, only used when split screen is activated.
     private boolean mIsDividerShown = true;
     private boolean mStartMovingTasks;
+    // Whether the animation to home should be handed off to another handler once the gesture is
+    // committed.
+    protected boolean mHandOffAnimationToHome = false;
 
     @Nullable
     private RemoteAnimationTargets.ReleaseCheck mSwipePipToHomeReleaseCheck = null;
@@ -945,6 +953,10 @@
         mSwipePipToHomeReleaseCheck = new RemoteAnimationTargets.ReleaseCheck();
         mSwipePipToHomeReleaseCheck.setCanRelease(true);
         mRecentsAnimationTargets.addReleaseCheck(mSwipePipToHomeReleaseCheck);
+        if (TransitionAnimator.Companion.longLivedReturnAnimationsEnabled()) {
+            mHandOffAnimationToHome =
+                    targets.extras.getBoolean(KEY_EXTRA_SHELL_CAN_HAND_OFF_ANIMATION, false);
+        }
 
         // Only initialize the device profile, if it has not been initialized before, as in some
         // configurations targets.homeContentInsets may not be correct.
@@ -1629,6 +1641,10 @@
                 }
                 windowAnim = createWindowAnimationToHome(start, homeAnimFactory);
 
+                if (mHandOffAnimationToHome) {
+                    handOffAnimation(velocityPxPerMs);
+                }
+
                 windowAnim[0].addAnimatorListener(new AnimationSuccessListener() {
                     @Override
                     public void onAnimationSuccess(Animator animator) {
@@ -1711,6 +1727,19 @@
         }
     }
 
+    private void handOffAnimation(PointF velocityPxPerMs) {
+        if (!TransitionAnimator.Companion.longLivedReturnAnimationsEnabled()
+                || mRecentsAnimationController == null) {
+            return;
+        }
+
+        Pair<RemoteAnimationTarget[], WindowAnimationState[]> targetsAndStates =
+                extractTargetsAndStates(mRemoteTargetHandles, velocityPxPerMs);
+        mRecentsAnimationController.handOffAnimation(
+                targetsAndStates.first, targetsAndStates.second);
+        ActiveGestureProtoLogProxy.logHandOffAnimation();
+    }
+
     private int calculateWindowRotation(RemoteAnimationTarget runningTaskTarget,
             RecentsOrientedState orientationState) {
         if (runningTaskTarget.rotationChange != 0) {
diff --git a/quickstep/src/com/android/quickstep/LauncherBackAnimationController.java b/quickstep/src/com/android/quickstep/LauncherBackAnimationController.java
index 6719ab7..f3ed491 100644
--- a/quickstep/src/com/android/quickstep/LauncherBackAnimationController.java
+++ b/quickstep/src/com/android/quickstep/LauncherBackAnimationController.java
@@ -49,8 +49,8 @@
 import android.window.BackEvent;
 import android.window.BackMotionEvent;
 import android.window.BackProgressAnimator;
+import android.window.IBackAnimationHandoffHandler;
 import android.window.IOnBackInvokedCallback;
-
 import com.android.app.animation.Interpolators;
 import com.android.internal.policy.SystemBarUtils;
 import com.android.internal.view.AppearanceRegion;
@@ -225,6 +225,12 @@
         public void setTriggerBack(boolean triggerBack) {
             // TODO(b/261654570): track touch from the Launcher process.
         }
+
+        @Override
+        public void setHandoffHandler(IBackAnimationHandoffHandler unused) {
+            // For now, Launcher handles this internally so it doesn't need to hand off the
+            // animation.
+        }
     }
 
     private static class RemoteAnimationRunnerStub extends IRemoteAnimationRunner.Stub {
diff --git a/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java b/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java
index dacafd4..6087dc2 100644
--- a/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java
+++ b/quickstep/src/com/android/quickstep/LauncherSwipeHandlerV2.java
@@ -46,7 +46,6 @@
 import com.android.launcher3.views.FloatingIconView;
 import com.android.launcher3.views.FloatingView;
 import com.android.launcher3.widget.LauncherAppWidgetHostView;
-import com.android.quickstep.fallback.window.RecentsWindowManager;
 import com.android.quickstep.util.RectFSpringAnim;
 import com.android.quickstep.util.ScalingWorkspaceRevealAnim;
 import com.android.quickstep.util.StaggeredWorkspaceAnim;
@@ -54,6 +53,7 @@
 import com.android.quickstep.views.FloatingWidgetView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.TaskView;
+import com.android.systemui.animation.TransitionAnimator;
 import com.android.systemui.shared.system.InputConsumerController;
 
 import java.util.Collections;
@@ -108,7 +108,9 @@
 
         mContainer.getRootView().setForceHideBackArrow(true);
 
-        if (!canUseWorkspaceView || appCanEnterPip || mIsSwipeForSplit) {
+        boolean handOffAnimation = TransitionAnimator.Companion.longLivedReturnAnimationsEnabled()
+                && mHandOffAnimationToHome;
+        if (handOffAnimation || !canUseWorkspaceView || appCanEnterPip || mIsSwipeForSplit) {
             return new LauncherHomeAnimationFactory() {
 
                 @Nullable
diff --git a/quickstep/src/com/android/quickstep/RecentsAnimationController.java b/quickstep/src/com/android/quickstep/RecentsAnimationController.java
index 60fcff8..145773d 100644
--- a/quickstep/src/com/android/quickstep/RecentsAnimationController.java
+++ b/quickstep/src/com/android/quickstep/RecentsAnimationController.java
@@ -21,9 +21,11 @@
 import android.os.Bundle;
 import android.os.RemoteException;
 import android.util.Log;
+import android.view.RemoteAnimationTarget;
 import android.view.SurfaceControl;
 import android.view.WindowManagerGlobal;
 import android.window.PictureInPictureSurfaceTransaction;
+import android.window.WindowAnimationState;
 
 import androidx.annotation.UiThread;
 
@@ -32,6 +34,7 @@
 import com.android.launcher3.util.Preconditions;
 import com.android.launcher3.util.RunnableList;
 import com.android.quickstep.util.ActiveGestureProtoLogProxy;
+import com.android.systemui.animation.TransitionAnimator;
 import com.android.systemui.shared.recents.model.ThumbnailData;
 import com.android.systemui.shared.system.InteractionJankMonitorWrapper;
 import com.android.systemui.shared.system.RecentsAnimationControllerCompat;
@@ -90,6 +93,16 @@
     }
 
     @UiThread
+    public void handOffAnimation(RemoteAnimationTarget[] targets, WindowAnimationState[] states) {
+        if (TransitionAnimator.Companion.longLivedReturnAnimationsEnabled()) {
+            UI_HELPER_EXECUTOR.execute(() -> mController.handOffAnimation(targets, states));
+        } else {
+            Log.e(TAG, "Tried to hand off the animation, but the feature is disabled",
+                    new Exception());
+        }
+    }
+
+    @UiThread
     public void finishAnimationToHome() {
         finishController(true /* toRecents */, null, false /* sendUserLeaveHint */);
     }
diff --git a/quickstep/src/com/android/quickstep/TaskViewUtils.java b/quickstep/src/com/android/quickstep/TaskViewUtils.java
index 07ee479..783c87c 100644
--- a/quickstep/src/com/android/quickstep/TaskViewUtils.java
+++ b/quickstep/src/com/android/quickstep/TaskViewUtils.java
@@ -47,12 +47,15 @@
 import android.content.Context;
 import android.graphics.Matrix;
 import android.graphics.Matrix.ScaleToFit;
+import android.graphics.PointF;
 import android.graphics.Rect;
 import android.graphics.RectF;
+import android.util.Pair;
 import android.view.RemoteAnimationTarget;
 import android.view.SurfaceControl;
 import android.view.View;
 import android.window.TransitionInfo;
+import android.window.WindowAnimationState;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -784,4 +787,43 @@
         animatorHandler.accept(dockFadeAnimator);
         return dockFadeAnimator;
     }
+
+    /**
+     * Creates an array of {@link RemoteAnimationTarget}s and a matching array of
+     * {@link WindowAnimationState}s from the provided handles.
+     * Important: the ordering of the two arrays is the same, so the state at each index of the
+     * second applies to the target in the same index of the first.
+     *
+     * @param handles The handles wrapping each target.
+     * @param velocityPxPerMs The current velocity of the target animations.
+     */
+    @NonNull
+    public static Pair<RemoteAnimationTarget[], WindowAnimationState[]> extractTargetsAndStates(
+            @NonNull RemoteTargetHandle[] handles, @NonNull PointF velocityPxPerMs) {
+        RemoteAnimationTarget[] targets = new RemoteAnimationTarget[handles.length];
+        WindowAnimationState[] animationStates = new WindowAnimationState[handles.length];
+        long timestamp = System.currentTimeMillis();
+
+        for (int i = 0; i < handles.length; i++) {
+            targets[i] = handles[i].getTransformParams().getTargetSet().apps[i];
+
+            TaskViewSimulator taskViewSimulator = handles[i].getTaskViewSimulator();
+            RectF startRect = taskViewSimulator.getCurrentRect();
+            float cornerRadius = taskViewSimulator.getCurrentCornerRadius();
+
+            WindowAnimationState state = new WindowAnimationState();
+            state.timestamp = timestamp;
+            state.bounds = new RectF(
+                    startRect.left, startRect.top, startRect.right, startRect.bottom);
+            state.topLeftRadius = cornerRadius;
+            state.topRightRadius = cornerRadius;
+            state.bottomRightRadius = cornerRadius;
+            state.bottomLeftRadius = cornerRadius;
+            state.velocityPxPerMs = velocityPxPerMs;
+
+            animationStates[i] = state;
+        }
+
+        return new Pair<>(targets, animationStates);
+    }
 }
diff --git a/quickstep/src_protolog/com/android/quickstep/util/ActiveGestureProtoLogProxy.java b/quickstep/src_protolog/com/android/quickstep/util/ActiveGestureProtoLogProxy.java
index f43a125..0091036 100644
--- a/quickstep/src_protolog/com/android/quickstep/util/ActiveGestureProtoLogProxy.java
+++ b/quickstep/src_protolog/com/android/quickstep/util/ActiveGestureProtoLogProxy.java
@@ -96,6 +96,12 @@
                 + "force finish recents animation complete; clearing state callback.");
     }
 
+    public static void logHandOffAnimation() {
+        ActiveGestureLog.INSTANCE.addLog("AbsSwipeUpHandler.handOffAnimation");
+        if (!enableActiveGestureProtoLog()) return;
+        ProtoLog.d(ACTIVE_GESTURE_LOG, "AbsSwipeUpHandler.handOffAnimation");
+    }
+
     public static void logFinishRecentsAnimationOnTasksAppeared() {
         ActiveGestureLog.INSTANCE.addLog("finishRecentsAnimationOnTasksAppeared");
         if (!enableActiveGestureProtoLog()) return;
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarNavButtonControllerTest.java b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarNavButtonControllerTest.java
index 4b04dba..6ebae49 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarNavButtonControllerTest.java
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarNavButtonControllerTest.java
@@ -15,9 +15,11 @@
 import static com.android.launcher3.taskbar.TaskbarNavButtonController.BUTTON_RECENTS;
 import static com.android.launcher3.taskbar.TaskbarNavButtonController.SCREEN_PIN_LONG_PRESS_THRESHOLD;
 import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_SCREEN_PINNING;
+import static com.android.window.flags.Flags.FLAG_PREDICTIVE_BACK_THREE_BUTTON_NAV;
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.junit.Assert.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
@@ -28,6 +30,10 @@
 import static org.mockito.Mockito.when;
 
 import android.os.Handler;
+import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
+import android.view.KeyEvent;
 import android.view.View;
 import android.view.inputmethod.Flags;
 
@@ -43,8 +49,10 @@
 import com.android.systemui.contextualeducation.GestureType;
 
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -76,6 +84,9 @@
     @Mock
     View mockView;
 
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
     private int mHomePressCount;
     private int mOverviewToggleCount;
     private final TaskbarNavButtonCallbacks mCallbacks = new TaskbarNavButtonCallbacks() {
@@ -333,4 +344,46 @@
         verify(mockStatsLogger, times(1)).log(LAUNCHER_TASKBAR_BACK_BUTTON_LONGPRESS);
         verify(mockStatsLogger, times(0)).log(LAUNCHER_TASKBAR_BACK_BUTTON_TAP);
     }
+
+    @Test
+    @RequiresFlagsEnabled(FLAG_PREDICTIVE_BACK_THREE_BUTTON_NAV)
+    public void testPredictiveBackInvoked() {
+        ArgumentCaptor<KeyEvent> keyEventCaptor = ArgumentCaptor.forClass(KeyEvent.class);
+        mNavButtonController.sendBackKeyEvent(KeyEvent.ACTION_DOWN, false);
+        mNavButtonController.sendBackKeyEvent(KeyEvent.ACTION_UP, false);
+        verify(mockSystemUiProxy, times(2)).onBackEvent(keyEventCaptor.capture());
+        verifyKeyEvent(keyEventCaptor.getAllValues().getFirst(), KeyEvent.ACTION_DOWN, false);
+        verifyKeyEvent(keyEventCaptor.getAllValues().getFirst(), KeyEvent.ACTION_UP, false);
+    }
+
+    @Test
+    @RequiresFlagsEnabled(FLAG_PREDICTIVE_BACK_THREE_BUTTON_NAV)
+    public void testPredictiveBackCancelled() {
+        ArgumentCaptor<KeyEvent> keyEventCaptor = ArgumentCaptor.forClass(KeyEvent.class);
+        mNavButtonController.sendBackKeyEvent(KeyEvent.ACTION_DOWN, false);
+        mNavButtonController.sendBackKeyEvent(KeyEvent.ACTION_UP, true);
+        verify(mockSystemUiProxy, times(2)).onBackEvent(keyEventCaptor.capture());
+        verifyKeyEvent(keyEventCaptor.getAllValues().getFirst(), KeyEvent.ACTION_DOWN, false);
+        verifyKeyEvent(keyEventCaptor.getAllValues().getFirst(), KeyEvent.ACTION_UP, true);
+    }
+
+    @Test
+    @RequiresFlagsEnabled(FLAG_PREDICTIVE_BACK_THREE_BUTTON_NAV)
+    public void testButtonsDisabledWhileBackPressed() {
+        mNavButtonController.sendBackKeyEvent(KeyEvent.ACTION_DOWN, false);
+        mNavButtonController.onButtonClick(BUTTON_HOME, mockView);
+        mNavButtonController.onButtonClick(BUTTON_RECENTS, mockView);
+        mNavButtonController.onButtonLongClick(BUTTON_A11Y, mockView);
+        mNavButtonController.onButtonClick(BUTTON_IME_SWITCH, mockView);
+        mNavButtonController.sendBackKeyEvent(KeyEvent.ACTION_UP, false);
+        assertThat(mHomePressCount).isEqualTo(0);
+        verify(mockSystemUiProxy, never()).notifyAccessibilityButtonLongClicked();
+        assertThat(mOverviewToggleCount).isEqualTo(0);
+        verify(mockSystemUiProxy, never()).onImeSwitcherPressed();
+    }
+
+    private void verifyKeyEvent(KeyEvent keyEvent, int action, boolean isCancelled) {
+        assertEquals(isCancelled, keyEvent.isCanceled());
+        assertEquals(action, KeyEvent.ACTION_DOWN, keyEvent.getAction());
+    }
 }
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
new file mode 100644
index 0000000..cc8582c
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
@@ -0,0 +1,313 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar
+
+import android.content.ComponentName
+import android.content.Intent
+import android.platform.test.annotations.EnableFlags
+import android.platform.test.flag.junit.SetFlagsRule
+import com.android.launcher3.Flags.FLAG_TASKBAR_OVERFLOW
+import com.android.launcher3.taskbar.TaskbarControllerTestUtil.runOnMainSync
+import com.android.launcher3.taskbar.bubbles.BubbleBarViewController
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.taskbar.rules.MockedRecentsModelTestRule
+import com.android.launcher3.taskbar.rules.TaskbarModeRule
+import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.PINNED
+import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.TRANSIENT
+import com.android.launcher3.taskbar.rules.TaskbarModeRule.TaskbarMode
+import com.android.launcher3.taskbar.rules.TaskbarUnitTestRule
+import com.android.launcher3.taskbar.rules.TaskbarUnitTestRule.InjectController
+import com.android.launcher3.taskbar.rules.TaskbarWindowSandboxContext
+import com.android.launcher3.util.LauncherMultivalentJUnit
+import com.android.launcher3.util.LauncherMultivalentJUnit.EmulatedDevices
+import com.android.launcher3.util.TestUtil.getOnUiThread
+import com.android.quickstep.SystemUiProxy
+import com.android.quickstep.util.DesktopTask
+import com.android.systemui.shared.recents.model.Task
+import com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_WINDOWING_MODE
+import com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_WINDOWING_TASKBAR_RUNNING_APPS
+import com.android.wm.shell.Flags.FLAG_ENABLE_BUBBLE_BAR
+import com.android.wm.shell.desktopmode.IDesktopTaskListener
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(LauncherMultivalentJUnit::class)
+@EmulatedDevices(["pixelTablet2023"])
+@EnableFlags(
+    FLAG_TASKBAR_OVERFLOW,
+    FLAG_ENABLE_DESKTOP_WINDOWING_TASKBAR_RUNNING_APPS,
+    FLAG_ENABLE_DESKTOP_WINDOWING_MODE,
+    FLAG_ENABLE_BUBBLE_BAR,
+)
+class TaskbarOverflowTest {
+    @get:Rule(order = 0) val setFlagsRule = SetFlagsRule()
+
+    @get:Rule(order = 1)
+    val context =
+        TaskbarWindowSandboxContext.create { builder ->
+            builder.bindSystemUiProxy(
+                object : SystemUiProxy(this) {
+                    override fun setDesktopTaskListener(listener: IDesktopTaskListener?) {
+                        desktopTaskListener = listener
+                    }
+                }
+            )
+        }
+
+    @get:Rule(order = 2) val recentsModel = MockedRecentsModelTestRule(context)
+
+    @get:Rule(order = 3) val taskbarModeRule = TaskbarModeRule(context)
+
+    @get:Rule(order = 4) val taskbarUnitTestRule = TaskbarUnitTestRule(this, context)
+
+    @InjectController lateinit var taskbarViewController: TaskbarViewController
+    @InjectController lateinit var recentAppsController: TaskbarRecentAppsController
+    @InjectController lateinit var bubbleBarViewController: BubbleBarViewController
+    @InjectController lateinit var bubbleStashController: BubbleStashController
+
+    private var desktopTaskListener: IDesktopTaskListener? = null
+
+    @Before
+    fun ensureRunningAppsShowing() {
+        runOnMainSync {
+            if (!recentAppsController.canShowRunningApps) {
+                recentAppsController.onDestroy()
+                recentAppsController.canShowRunningApps = true
+                recentAppsController.init(taskbarUnitTestRule.activityContext.controllers)
+            }
+            recentsModel.resolvePendingTaskRequests()
+        }
+    }
+
+    @Test
+    @TaskbarMode(PINNED)
+    fun testTaskbarWithMaxNumIcons_pinned() {
+        addRunningAppsAndVerifyOverflowState(0)
+
+        assertThat(taskbarIconsCentered).isTrue()
+        assertThat(taskbarEndMargin).isAtLeast(navButtonEndSpacing)
+    }
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testTaskbarWithMaxNumIcons_transient() {
+        addRunningAppsAndVerifyOverflowState(0)
+
+        assertThat(taskbarIconsCentered).isTrue()
+        assertThat(taskbarEndMargin).isAtLeast(navButtonEndSpacing)
+    }
+
+    @Test
+    @TaskbarMode(PINNED)
+    fun testOverflownTaskbar_pinned() {
+        addRunningAppsAndVerifyOverflowState(5)
+
+        assertThat(taskbarIconsCentered).isTrue()
+        assertThat(taskbarEndMargin).isAtLeast(navButtonEndSpacing)
+    }
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testOverflownTaskbar_transient() {
+        addRunningAppsAndVerifyOverflowState(5)
+
+        assertThat(taskbarIconsCentered).isTrue()
+        assertThat(taskbarEndMargin).isAtLeast(navButtonEndSpacing)
+    }
+
+    @Test
+    @TaskbarMode(PINNED)
+    fun testBubbleBarReducesTaskbarMaxNumIcons_pinned() {
+        var initialMaxNumIconViews = maxNumberOfTaskbarIcons
+        assertThat(initialMaxNumIconViews).isGreaterThan(0)
+
+        runOnMainSync { bubbleBarViewController.setHiddenForBubbles(false) }
+
+        val maxNumIconViews = addRunningAppsAndVerifyOverflowState(2)
+        assertThat(maxNumIconViews).isLessThan(initialMaxNumIconViews)
+
+        assertThat(taskbarIconsCentered).isTrue()
+    }
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testBubbleBarReducesTaskbarMaxNumIcons_transient() {
+        var initialMaxNumIconViews = maxNumberOfTaskbarIcons
+        assertThat(initialMaxNumIconViews).isGreaterThan(0)
+
+        runOnMainSync { bubbleBarViewController.setHiddenForBubbles(false) }
+
+        val maxNumIconViews = addRunningAppsAndVerifyOverflowState(2)
+        assertThat(maxNumIconViews).isLessThan(initialMaxNumIconViews)
+
+        assertThat(taskbarIconsCentered).isTrue()
+        assertThat(taskbarEndMargin)
+            .isAtLeast(
+                navButtonEndSpacing +
+                    bubbleBarViewController.collapsedWidthWithMaxVisibleBubbles.toInt()
+            )
+    }
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testBubbleBarReducesTaskbarMaxNumIcons_transientBubbleInitiallyStashed() {
+        var initialMaxNumIconViews = maxNumberOfTaskbarIcons
+        assertThat(initialMaxNumIconViews).isGreaterThan(0)
+        runOnMainSync {
+            bubbleStashController.stashBubbleBarImmediate()
+            bubbleBarViewController.setHiddenForBubbles(false)
+        }
+
+        val maxNumIconViews = addRunningAppsAndVerifyOverflowState(2)
+        assertThat(maxNumIconViews).isLessThan(initialMaxNumIconViews)
+
+        assertThat(taskbarIconsCentered).isTrue()
+        assertThat(taskbarEndMargin)
+            .isAtLeast(
+                navButtonEndSpacing +
+                    bubbleBarViewController.collapsedWidthWithMaxVisibleBubbles.toInt()
+            )
+    }
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testStashingBubbleBarMaintainsMaxNumIcons_transient() {
+        runOnMainSync { bubbleBarViewController.setHiddenForBubbles(false) }
+
+        val initialNumIcons = currentNumberOfTaskbarIcons
+        val maxNumIconViews = addRunningAppsAndVerifyOverflowState(2)
+
+        runOnMainSync { bubbleStashController.stashBubbleBarImmediate() }
+        assertThat(maxNumberOfTaskbarIcons).isEqualTo(maxNumIconViews)
+        assertThat(currentNumberOfTaskbarIcons).isEqualTo(maxNumIconViews)
+        assertThat(taskbarOverflowIconIndex).isEqualTo(initialNumIcons.coerceAtLeast(2))
+    }
+
+    @Test
+    @TaskbarMode(PINNED)
+    fun testHidingBubbleBarIncreasesMaxNumIcons_pinned() {
+        runOnMainSync { bubbleBarViewController.setHiddenForBubbles(false) }
+
+        val initialNumIcons = currentNumberOfTaskbarIcons
+        val initialMaxNumIconViews = addRunningAppsAndVerifyOverflowState(5)
+
+        runOnMainSync { bubbleBarViewController.setHiddenForBubbles(true) }
+
+        val maxNumIconViews = maxNumberOfTaskbarIcons
+        assertThat(maxNumIconViews).isGreaterThan(initialMaxNumIconViews)
+        assertThat(currentNumberOfTaskbarIcons).isEqualTo(maxNumIconViews)
+        assertThat(taskbarOverflowIconIndex).isEqualTo(initialNumIcons.coerceAtLeast(2))
+
+        assertThat(taskbarIconsCentered).isTrue()
+    }
+
+    @Test
+    @TaskbarMode(TRANSIENT)
+    fun testHidingBubbleBarIncreasesMaxNumIcons_transient() {
+        runOnMainSync { bubbleBarViewController.setHiddenForBubbles(false) }
+
+        val initialNumIcons = currentNumberOfTaskbarIcons
+        val initialMaxNumIconViews = addRunningAppsAndVerifyOverflowState(5)
+
+        runOnMainSync { bubbleBarViewController.setHiddenForBubbles(true) }
+
+        val maxNumIconViews = maxNumberOfTaskbarIcons
+        assertThat(maxNumIconViews).isGreaterThan(initialMaxNumIconViews)
+        assertThat(currentNumberOfTaskbarIcons).isEqualTo(maxNumIconViews)
+        assertThat(taskbarOverflowIconIndex).isEqualTo(initialNumIcons.coerceAtLeast(2))
+
+        assertThat(taskbarIconsCentered).isTrue()
+    }
+
+    private fun createDesktopTask(tasksToAdd: Int) {
+        val tasks =
+            (0..<tasksToAdd).map {
+                Task(Task.TaskKey(it, 0, Intent(), ComponentName("", ""), 0, 2000))
+            }
+        recentsModel.updateRecentTasks(listOf(DesktopTask(tasks)))
+        desktopTaskListener?.onTasksVisibilityChanged(
+            context.virtualDisplay.display.displayId,
+            tasksToAdd,
+        )
+        runOnMainSync { recentsModel.resolvePendingTaskRequests() }
+    }
+
+    private val navButtonEndSpacing: Int
+        get() {
+            return taskbarUnitTestRule.activityContext.resources.getDimensionPixelSize(
+                taskbarUnitTestRule.activityContext.deviceProfile.inv.inlineNavButtonsEndSpacing
+            )
+        }
+
+    private val taskbarOverflowIconIndex: Int
+        get() {
+            return getOnUiThread {
+                taskbarViewController.iconViews.indexOfFirst { it is TaskbarOverflowView }
+            }
+        }
+
+    private val maxNumberOfTaskbarIcons: Int
+        get() = getOnUiThread { taskbarViewController.maxNumIconViews }
+
+    private val currentNumberOfTaskbarIcons: Int
+        get() = getOnUiThread { taskbarViewController.iconViews.size }
+
+    private val taskbarIconsCentered: Boolean
+        get() {
+            return getOnUiThread {
+                val iconLayoutBounds = taskbarViewController.iconLayoutBounds
+                val availableWidth = taskbarUnitTestRule.activityContext.deviceProfile.widthPx
+                iconLayoutBounds.left - (availableWidth - iconLayoutBounds.right) < 2
+            }
+        }
+
+    private val taskbarEndMargin: Int
+        get() {
+            return getOnUiThread {
+                taskbarUnitTestRule.activityContext.deviceProfile.widthPx -
+                    taskbarViewController.iconLayoutBounds.right
+            }
+        }
+
+    /**
+     * Adds enough running apps for taskbar to enter overflow of `targetOverflowSize`, and verifies
+     * * max number of icons in the taskbar remains unchanged
+     * * number of icons in the taskbar is at most max number of icons
+     * * whether the taskbar overflow icon is shown, and its position in taskbar.
+     *
+     * Returns max number of icons.
+     */
+    private fun addRunningAppsAndVerifyOverflowState(targetOverflowSize: Int): Int {
+        val maxNumIconViews = maxNumberOfTaskbarIcons
+        assertThat(maxNumIconViews).isGreaterThan(0)
+        // Assume there are at least all apps and divider icon, as they would appear once running
+        // apps are added, even if not present initially.
+        val initialIconCount = currentNumberOfTaskbarIcons.coerceAtLeast(2)
+        assertThat(initialIconCount).isLessThan(maxNumIconViews)
+
+        createDesktopTask(maxNumIconViews - initialIconCount + targetOverflowSize)
+
+        assertThat(maxNumberOfTaskbarIcons).isEqualTo(maxNumIconViews)
+        assertThat(currentNumberOfTaskbarIcons).isEqualTo(maxNumIconViews)
+        assertThat(taskbarOverflowIconIndex)
+            .isEqualTo(if (targetOverflowSize > 0) initialIconCount else -1)
+        return maxNumIconViews
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
new file mode 100644
index 0000000..ed1443d
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.rules
+
+import com.android.quickstep.RecentsModel
+import com.android.quickstep.RecentsModel.RecentTasksChangedListener
+import com.android.quickstep.TaskIconCache
+import com.android.quickstep.util.GroupTask
+import java.util.function.Consumer
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+class MockedRecentsModelTestRule(private val context: TaskbarWindowSandboxContext) : TestRule {
+
+    private val mockIconCache: TaskIconCache = mock()
+
+    private val mockRecentsModel: RecentsModel = mock {
+        on { iconCache } doReturn mockIconCache
+
+        on { unregisterRecentTasksChangedListener() } doAnswer { recentTasksChangedListener = null }
+
+        on { registerRecentTasksChangedListener(any<RecentTasksChangedListener>()) } doAnswer
+            {
+                recentTasksChangedListener = it.getArgument<RecentTasksChangedListener>(0)
+            }
+
+        on { getTasks(anyOrNull(), anyOrNull()) } doAnswer
+            {
+                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
+                if (request != null) {
+                    taskRequests.add { response -> request.accept(response) }
+                }
+                taskListId
+            }
+
+        on { getTasks(anyOrNull()) } doAnswer
+            {
+                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
+                if (request != null) {
+                    taskRequests.add { response -> request.accept(response) }
+                }
+                taskListId
+            }
+
+        on { isTaskListValid(any()) } doAnswer { taskListId == it.getArgument(0) }
+    }
+
+    private var recentTasks: List<GroupTask> = emptyList()
+    private var taskListId = 0
+    private var recentTasksChangedListener: RecentTasksChangedListener? = null
+    private var taskRequests: MutableList<(List<GroupTask>) -> Unit> = mutableListOf()
+
+    override fun apply(base: Statement?, description: Description?): Statement {
+        return object : Statement() {
+            override fun evaluate() {
+                context.putObject(RecentsModel.INSTANCE, mockRecentsModel)
+                base?.evaluate()
+            }
+        }
+    }
+
+    // NOTE: For the update to take effect, `resolvePendingTaskRequests()` needs to be called, so
+    // calbacks to any pending `RecentsModel.getTasks()` get called with the updated task list.
+    fun updateRecentTasks(tasks: List<GroupTask>) {
+        ++taskListId
+        recentTasks = tasks
+        recentTasksChangedListener?.onRecentTasksChanged()
+    }
+
+    fun resolvePendingTaskRequests() {
+        val requests = mutableListOf<(List<GroupTask>) -> Unit>()
+        requests.addAll(taskRequests)
+        taskRequests.clear()
+
+        requests.forEach { it(recentTasks) }
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/AbsSwipeUpHandlerTestCase.java b/quickstep/tests/multivalentTests/src/com/android/quickstep/AbsSwipeUpHandlerTestCase.java
index 6b95f8d..970bdec 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/AbsSwipeUpHandlerTestCase.java
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/AbsSwipeUpHandlerTestCase.java
@@ -17,6 +17,7 @@
 package com.android.quickstep;
 
 import static com.android.quickstep.AbsSwipeUpHandler.STATE_HANDLER_INVALIDATED;
+import static com.android.wm.shell.shared.ShellSharedConstants.KEY_EXTRA_SHELL_CAN_HAND_OFF_ANIMATION;
 
 import static junit.framework.TestCase.assertFalse;
 import static junit.framework.TestCase.assertTrue;
@@ -28,6 +29,7 @@
 import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -40,6 +42,9 @@
 import android.graphics.Rect;
 import android.os.Bundle;
 import android.os.SystemClock;
+import android.platform.test.annotations.DisableFlags;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.SetFlagsRule;
 import android.view.RemoteAnimationTarget;
 import android.view.SurfaceControl;
 import android.view.ViewTreeObserver;
@@ -58,6 +63,7 @@
 import com.android.quickstep.util.ContextInitListener;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.RecentsViewContainer;
+import com.android.systemui.shared.Flags;
 import com.android.systemui.shared.system.InputConsumerController;
 
 import org.junit.Before;
@@ -103,14 +109,8 @@
             /* startBounds= */ null,
             /* taskInfo= */ mRunningTaskInfo,
             /* allowEnterPip= */ false);
-    protected final RecentsAnimationTargets mRecentsAnimationTargets = new RecentsAnimationTargets(
-            new RemoteAnimationTarget[] {mRemoteAnimationTarget},
-            new RemoteAnimationTarget[] {mRemoteAnimationTarget},
-            new RemoteAnimationTarget[] {mRemoteAnimationTarget},
-            /* homeContentInsets= */ new Rect(),
-            /* minimizedHomeBounds= */ null,
-            new Bundle());
 
+    protected RecentsAnimationTargets mRecentsAnimationTargets;
     protected TaskAnimationManager mTaskAnimationManager;
     protected RecentsAnimationDeviceState mRecentsAnimationDeviceState;
 
@@ -127,6 +127,22 @@
     @Rule
     public final MockitoRule mMockitoRule = MockitoJUnit.rule();
 
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+    @Before
+    public void setUpAnimationTargets() {
+        Bundle extras = new Bundle();
+        extras.putBoolean(KEY_EXTRA_SHELL_CAN_HAND_OFF_ANIMATION, true);
+        mRecentsAnimationTargets = new RecentsAnimationTargets(
+                new RemoteAnimationTarget[] {mRemoteAnimationTarget},
+                new RemoteAnimationTarget[] {mRemoteAnimationTarget},
+                new RemoteAnimationTarget[] {mRemoteAnimationTarget},
+                /* homeContentInsets= */ new Rect(),
+                /* minimizedHomeBounds= */ null,
+                extras);
+    }
+
     @Before
     public void setUpRunningTaskInfo() {
         mRunningTaskInfo.baseIntent = new Intent(Intent.ACTION_MAIN)
@@ -237,6 +253,30 @@
         });
     }
 
+    @EnableFlags({Flags.FLAG_RETURN_ANIMATION_FRAMEWORK_LIBRARY,
+            Flags.FLAG_RETURN_ANIMATION_FRAMEWORK_LONG_LIVED})
+    @Test
+    public void testHomeGesture_handsOffAnimation() {
+        createSwipeUpHandlerForGesture(GestureState.GestureEndTarget.HOME);
+
+        runOnMainSync(() -> {
+            verify(mRecentsAnimationController).handOffAnimation(any(), any());
+            verifyRecentsAnimationFinishedAndCallCallback();
+        });
+    }
+
+    @DisableFlags({Flags.FLAG_RETURN_ANIMATION_FRAMEWORK_LIBRARY,
+            Flags.FLAG_RETURN_ANIMATION_FRAMEWORK_LONG_LIVED})
+    @Test
+    public void testHomeGesture_doesNotHandOffAnimation_withFlagsDisabled() {
+        createSwipeUpHandlerForGesture(GestureState.GestureEndTarget.HOME);
+
+        runOnMainSync(() -> {
+            verify(mRecentsAnimationController, never()).handOffAnimation(any(), any());
+            verifyRecentsAnimationFinishedAndCallCallback();
+        });
+    }
+
     @Test
     public void testHomeGesture_invalidatesHandlerAfterParallelAnim() {
         ValueAnimator parallelAnim = new ValueAnimator();
diff --git a/src/com/android/launcher3/model/DatabaseHelper.java b/src/com/android/launcher3/model/DatabaseHelper.java
index 8368256..ed4f492 100644
--- a/src/com/android/launcher3/model/DatabaseHelper.java
+++ b/src/com/android/launcher3/model/DatabaseHelper.java
@@ -56,6 +56,7 @@
 import java.net.URISyntaxException;
 import java.util.Arrays;
 import java.util.Locale;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.ToLongFunction;
 import java.util.stream.Collectors;
 
@@ -79,8 +80,8 @@
     private final Context mContext;
     private final ToLongFunction<UserHandle> mUserSerialProvider;
     private final Runnable mOnEmptyDbCreateCallback;
+    private final AtomicInteger mMaxItemId = new AtomicInteger(-1);
 
-    private int mMaxItemId = -1;
     public boolean mHotseatRestoreTableExists;
 
     /**
@@ -97,21 +98,19 @@
     protected void initIds() {
         // In the case where neither onCreate nor onUpgrade gets called, we read the maxId from
         // the DB here
-        if (mMaxItemId == -1) {
-            mMaxItemId = initializeMaxItemId(getWritableDatabase());
-        }
+        mMaxItemId.compareAndSet(-1, initializeMaxItemId(getWritableDatabase()));
     }
 
     @Override
     public void onCreate(SQLiteDatabase db) {
         if (LOGD) Log.d(TAG, "creating new launcher database");
 
-        mMaxItemId = 1;
+        mMaxItemId.set(1);
 
         addTableToDb(db, getDefaultUserSerial(), false /* optional */);
 
         // Fresh and clean launcher DB.
-        mMaxItemId = initializeMaxItemId(db);
+        mMaxItemId.set(initializeMaxItemId(db));
         mOnEmptyDbCreateCallback.run();
     }
 
@@ -451,11 +450,10 @@
     // after that point
     @Override
     public int generateNewItemId() {
-        if (mMaxItemId < 0) {
+        if (mMaxItemId.get() < 0) {
             throw new RuntimeException("Error: max item id was not initialized");
         }
-        mMaxItemId += 1;
-        return mMaxItemId;
+        return mMaxItemId.incrementAndGet();
     }
 
     /**
@@ -484,7 +482,7 @@
 
     public void checkId(ContentValues values) {
         int id = values.getAsInteger(Favorites._ID);
-        mMaxItemId = Math.max(id, mMaxItemId);
+        mMaxItemId.accumulateAndGet(id, Math::max);
     }
 
     private int initializeMaxItemId(SQLiteDatabase db) {
@@ -508,7 +506,7 @@
         int count = loader.loadLayout(db);
 
         // Ensure that the max ids are initialized
-        mMaxItemId = initializeMaxItemId(db);
+        mMaxItemId.set(initializeMaxItemId(db));
         return count;
     }