Merge "Create specs for hotseat" into udc-qpr-dev
diff --git a/protos/launcher_atom.proto b/protos/launcher_atom.proto
index 63ea20c..f8b08f8 100644
--- a/protos/launcher_atom.proto
+++ b/protos/launcher_atom.proto
@@ -135,7 +135,7 @@
   }
 }
 
-// Next value 51
+// Next value 52
 enum Attribute {
   option allow_alias = true;
 
@@ -187,6 +187,7 @@
   ALL_APPS_SEARCH_RESULT_SYSTEM_POINTER = 42;
   ALL_APPS_SEARCH_RESULT_EDUCARD = 43;
   ALL_APPS_SEARCH_RESULT_LOCATION = 50;
+  ALL_APPS_SEARCH_RESULT_TEXT_HEADER = 51;
 
   // Result sources
   DATA_SOURCE_APPSEARCH_APP_PREVIEW = 45;
diff --git a/quickstep/src/com/android/launcher3/hybridhotseat/HotseatEduDialog.java b/quickstep/src/com/android/launcher3/hybridhotseat/HotseatEduDialog.java
index 5691ecf..db225be 100644
--- a/quickstep/src/com/android/launcher3/hybridhotseat/HotseatEduDialog.java
+++ b/quickstep/src/com/android/launcher3/hybridhotseat/HotseatEduDialog.java
@@ -158,11 +158,11 @@
     }
 
     private void animateOpen() {
-        if (mIsOpen || mOpenCloseAnimator.isRunning()) {
+        if (mIsOpen || mOpenCloseAnimation.getAnimationPlayer().isRunning()) {
             return;
         }
         mIsOpen = true;
-        setUpDefaultOpenAnimator().start();
+        setUpDefaultOpenAnimation().start();
     }
 
     @Override
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index b58ad38..9fe0c00 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -335,6 +335,11 @@
         mControllers.taskbarStashController.showTaskbarFromBroadcast();
     }
 
+    /** Toggles Taskbar All Apps overlay. */
+    public void toggleAllApps() {
+        mControllers.taskbarAllAppsController.toggle();
+    }
+
     @Override
     public DeviceProfile getDeviceProfile() {
         return mDeviceProfile;
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
index 528cb30..c423fb3 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarManager.java
@@ -22,6 +22,7 @@
 
 import static com.android.launcher3.LauncherPrefs.TASKBAR_PINNING;
 import static com.android.launcher3.LauncherPrefs.TASKBAR_PINNING_KEY;
+import static com.android.launcher3.LauncherState.OVERVIEW;
 import static com.android.launcher3.util.DisplayController.TASKBAR_NOT_DESTROYED_TAG;
 import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
 import static com.android.launcher3.util.FlagDebugUtils.formatFlagChange;
@@ -271,6 +272,25 @@
     }
 
     /**
+     * Toggles All Apps for Taskbar or Launcher depending on the current state.
+     *
+     * @param homeAllAppsIntent Intent used if Taskbar is not enabled or Launcher is resumed.
+     */
+    public void toggleAllApps(Intent homeAllAppsIntent) {
+        if (mTaskbarActivityContext == null) {
+            mContext.startActivity(homeAllAppsIntent);
+            return;
+        }
+
+        if (mActivity != null && mActivity.isResumed() && !mActivity.isInState(OVERVIEW)) {
+            mContext.startActivity(homeAllAppsIntent);
+            return;
+        }
+
+        mTaskbarActivityContext.toggleAllApps();
+    }
+
+    /**
      * Displays a frame of the first Launcher reveal animation.
      *
      * This should be used to run a first Launcher reveal animation whose progress matches a swipe
diff --git a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java
index 3fe7359..537d2c6 100644
--- a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsSlideInView.java
@@ -17,11 +17,13 @@
 
 import static com.android.app.animation.Interpolators.EMPHASIZED;
 
+import android.animation.Animator;
 import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.Rect;
 import android.util.AttributeSet;
 import android.view.MotionEvent;
+import android.view.View;
 import android.view.animation.Interpolator;
 import android.window.OnBackInvokedDispatcher;
 
@@ -29,6 +31,7 @@
 import com.android.launcher3.Insettable;
 import com.android.launcher3.R;
 import com.android.launcher3.anim.AnimatorListeners;
+import com.android.launcher3.anim.PendingAnimation;
 import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.taskbar.allapps.TaskbarAllAppsViewController.TaskbarAllAppsCallbacks;
 import com.android.launcher3.taskbar.overlay.TaskbarOverlayContext;
@@ -58,22 +61,50 @@
 
     /** Opens the all apps view. */
     void show(boolean animate) {
-        if (mIsOpen || mOpenCloseAnimator.isRunning()) {
+        if (mIsOpen || mOpenCloseAnimation.getAnimationPlayer().isRunning()) {
             return;
         }
         mIsOpen = true;
         attachToContainer();
-        mAllAppsCallbacks.onAllAppsTransitionStart(true);
 
-        if (animate) {
-            setUpOpenCloseAnimator(TRANSLATION_SHIFT_OPENED, EMPHASIZED);
-            mOpenCloseAnimator.addListener(AnimatorListeners.forEndCallback(
-                    () -> mAllAppsCallbacks.onAllAppsTransitionEnd(true)));
-            mOpenCloseAnimator.setDuration(mAllAppsCallbacks.getOpenDuration()).start();
-        } else {
-            mTranslationShift = TRANSLATION_SHIFT_OPENED;
+        addOnAttachStateChangeListener(new OnAttachStateChangeListener() {
+            @Override
+            public void onViewAttachedToWindow(View v) {
+                removeOnAttachStateChangeListener(this);
+                // Wait for view and its descendants to be fully attached before starting open.
+                post(() -> showOnFullyAttachedToWindow(animate));
+            }
+
+            @Override
+            public void onViewDetachedFromWindow(View v) {
+                removeOnAttachStateChangeListener(this);
+            }
+        });
+    }
+
+    private void showOnFullyAttachedToWindow(boolean animate) {
+        mAllAppsCallbacks.onAllAppsTransitionStart(true);
+        if (!animate) {
             mAllAppsCallbacks.onAllAppsTransitionEnd(true);
+            mTranslationShift = TRANSLATION_SHIFT_OPENED;
+            return;
         }
+
+        setUpOpenAnimation(mAllAppsCallbacks.getOpenDuration());
+        Animator animator = mOpenCloseAnimation.getAnimationPlayer();
+        animator.setInterpolator(EMPHASIZED);
+        animator.addListener(AnimatorListeners.forEndCallback(() -> {
+            if (mIsOpen) {
+                mAllAppsCallbacks.onAllAppsTransitionEnd(true);
+            }
+        }));
+        animator.start();
+    }
+
+    @Override
+    protected void onOpenCloseAnimationPending(PendingAnimation animation) {
+        mAllAppsCallbacks.onAllAppsAnimationPending(
+                animation, mToTranslationShift == TRANSLATION_SHIFT_OPENED);
     }
 
     /** The apps container inside this view. */
diff --git a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsViewController.java b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsViewController.java
index f43169b..85633e9 100644
--- a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarAllAppsViewController.java
@@ -20,6 +20,7 @@
 
 import com.android.launcher3.AbstractFloatingView;
 import com.android.launcher3.allapps.AllAppsTransitionListener;
+import com.android.launcher3.anim.PendingAnimation;
 import com.android.launcher3.appprediction.AppsDividerView;
 import com.android.launcher3.taskbar.NavbarButtonsViewController;
 import com.android.launcher3.taskbar.TaskbarControllers;
@@ -125,5 +126,9 @@
         boolean handleSearchBackInvoked() {
             return mSearchSessionController.handleBackInvoked();
         }
+
+        void onAllAppsAnimationPending(PendingAnimation animation, boolean toAllApps) {
+            mSearchSessionController.onAllAppsAnimationPending(animation, toAllApps);
+        }
     }
 }
diff --git a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarSearchSessionController.kt b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarSearchSessionController.kt
index c26977f..8a2041f 100644
--- a/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarSearchSessionController.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/allapps/TaskbarSearchSessionController.kt
@@ -20,6 +20,7 @@
 import android.view.View
 import com.android.launcher3.R
 import com.android.launcher3.allapps.AllAppsTransitionListener
+import com.android.launcher3.anim.PendingAnimation
 import com.android.launcher3.config.FeatureFlags
 import com.android.launcher3.dragndrop.DragOptions.PreDragCondition
 import com.android.launcher3.model.data.ItemInfo
@@ -50,6 +51,8 @@
 
     open fun handleBackInvoked(): Boolean = false
 
+    open fun onAllAppsAnimationPending(animation: PendingAnimation, toAllApps: Boolean) = Unit
+
     companion object {
         @JvmStatic
         fun newInstance(context: Context): TaskbarSearchSessionController {
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
index aeec2c6..2acd5d4 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhoneLandscapeNavLayoutter.kt
@@ -42,16 +42,15 @@
 
     override fun layoutButtons(dp: DeviceProfile, isContextualButtonShowing: Boolean) {
         // TODO(b/230395757): Polish pending, this is just to make it usable
-        val navContainerParams = navButtonContainer.layoutParams as FrameLayout.LayoutParams
         val endStartMargins = resources.getDimensionPixelSize(R.dimen.taskbar_nav_buttons_size)
         val taskbarDimensions = DimensionUtils.getTaskbarPhoneDimensions(dp, resources,
                 TaskbarManager.isPhoneMode(dp))
         navButtonContainer.removeAllViews()
         navButtonContainer.orientation = LinearLayout.VERTICAL
 
+        val navContainerParams = FrameLayout.LayoutParams(
+                taskbarDimensions.x, ViewGroup.LayoutParams.MATCH_PARENT)
         navContainerParams.apply {
-            width = taskbarDimensions.x
-            height = ViewGroup.LayoutParams.MATCH_PARENT
             topMargin = endStartMargins
             bottomMargin = endStartMargins
             marginEnd = 0
@@ -64,7 +63,7 @@
         navButtonContainer.addView(backButton)
 
         navButtonContainer.layoutParams = navContainerParams
-        navButtonContainer.gravity = Gravity.CENTER_HORIZONTAL
+        navButtonContainer.gravity = Gravity.CENTER
 
         // Add the spaces in between the nav buttons
         val spaceInBetween: Int =
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt
index e97e378..c763115 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/PhonePortraitNavLayoutter.kt
@@ -41,7 +41,6 @@
 
     override fun layoutButtons(dp: DeviceProfile, isContextualButtonShowing: Boolean) {
         // TODO(b/230395757): Polish pending, this is just to make it usable
-        val navContainerParams = navButtonContainer.layoutParams as FrameLayout.LayoutParams
         val taskbarDimensions =
             DimensionUtils.getTaskbarPhoneDimensions(dp, resources,
                     TaskbarManager.isPhoneMode(dp))
@@ -51,9 +50,9 @@
         navButtonContainer.removeAllViews()
         navButtonContainer.orientation = LinearLayout.HORIZONTAL
 
+        val navContainerParams = FrameLayout.LayoutParams(
+                taskbarDimensions.x, ViewGroup.LayoutParams.MATCH_PARENT)
         navContainerParams.apply {
-            width = taskbarDimensions.x
-            height = ViewGroup.LayoutParams.MATCH_PARENT
             topMargin = 0
             bottomMargin = 0
             marginEnd = endStartMargins
@@ -66,7 +65,7 @@
         navButtonContainer.addView(recentsButton)
 
         navButtonContainer.layoutParams = navContainerParams
-        navButtonContainer.gravity = Gravity.CENTER_VERTICAL
+        navButtonContainer.gravity = Gravity.CENTER
 
         // Add the spaces in between the nav buttons
         val spaceInBetween =
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/TaskbarNavLayoutter.kt b/quickstep/src/com/android/launcher3/taskbar/navbutton/TaskbarNavLayoutter.kt
index 5ec7ca0..8332b7d 100644
--- a/quickstep/src/com/android/launcher3/taskbar/navbutton/TaskbarNavLayoutter.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/TaskbarNavLayoutter.kt
@@ -40,7 +40,6 @@
 
     override fun layoutButtons(dp: DeviceProfile, isContextualButtonShowing: Boolean) {
         // Add spacing after the end of the last nav button
-        val navButtonParams = navButtonContainer.layoutParams as FrameLayout.LayoutParams
         var navMarginEnd = resources.getDimension(dp.inv.inlineNavButtonsEndSpacing).toInt()
         val contextualWidth = endContextualContainer.width
         // If contextual buttons are showing, we check if the end margin is enough for the
@@ -50,10 +49,10 @@
             navMarginEnd += resources.getDimensionPixelSize(R.dimen.taskbar_hotseat_nav_spacing) / 2
         }
 
+        val navButtonParams = FrameLayout.LayoutParams(
+                FrameLayout.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
         navButtonParams.apply {
-            gravity = Gravity.END
-            width = FrameLayout.LayoutParams.WRAP_CONTENT
-            height = ViewGroup.LayoutParams.MATCH_PARENT
+            gravity = Gravity.END or Gravity.CENTER_VERTICAL
             marginEnd = navMarginEnd
         }
         navButtonContainer.orientation = LinearLayout.HORIZONTAL
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index 45d2fb0..08ce794 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -33,6 +33,7 @@
 import static com.android.launcher3.LauncherState.OVERVIEW_MODAL_TASK;
 import static com.android.launcher3.LauncherState.OVERVIEW_SPLIT_SELECT;
 import static com.android.launcher3.compat.AccessibilityManagerCompat.sendCustomAccessibilityEvent;
+import static com.android.launcher3.config.FeatureFlags.ENABLE_SPLIT_FROM_DESKTOP_TO_WORKSPACE;
 import static com.android.launcher3.config.FeatureFlags.ENABLE_SPLIT_FROM_WORKSPACE_TO_WORKSPACE;
 import static com.android.launcher3.config.FeatureFlags.RECEIVE_UNFOLD_EVENTS_FROM_SYSUI;
 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_APP_LAUNCH_TAP;
@@ -261,6 +262,7 @@
         mDesktopVisibilityController = new DesktopVisibilityController(this);
         if (DesktopTaskView.DESKTOP_MODE_SUPPORTED) {
             mDesktopVisibilityController.registerSystemUiListener();
+            mSplitSelectStateController.initSplitFromDesktopController(this);
         }
         mHotseatPredictionController = new HotseatPredictionController(this);
 
diff --git a/quickstep/src/com/android/quickstep/SystemUiProxy.java b/quickstep/src/com/android/quickstep/SystemUiProxy.java
index c9b7d5e..e73b525 100644
--- a/quickstep/src/com/android/quickstep/SystemUiProxy.java
+++ b/quickstep/src/com/android/quickstep/SystemUiProxy.java
@@ -84,6 +84,7 @@
 import com.android.wm.shell.recents.IRecentTasksListener;
 import com.android.wm.shell.splitscreen.ISplitScreen;
 import com.android.wm.shell.splitscreen.ISplitScreenListener;
+import com.android.wm.shell.splitscreen.ISplitSelectListener;
 import com.android.wm.shell.startingsurface.IStartingWindow;
 import com.android.wm.shell.startingsurface.IStartingWindowListener;
 import com.android.wm.shell.transition.IShellTransitions;
@@ -128,6 +129,7 @@
     private IPipAnimationListener mPipAnimationListener;
     private IBubblesListener mBubblesListener;
     private ISplitScreenListener mSplitScreenListener;
+    private ISplitSelectListener mSplitSelectListener;
     private IStartingWindowListener mStartingWindowListener;
     private ILauncherUnlockAnimationController mLauncherUnlockAnimationController;
     private IRecentTasksListener mRecentTasksListener;
@@ -239,6 +241,7 @@
         setPipAnimationListener(mPipAnimationListener);
         setBubblesListener(mBubblesListener);
         registerSplitScreenListener(mSplitScreenListener);
+        registerSplitSelectListener(mSplitSelectListener);
         setStartingWindowListener(mStartingWindowListener);
         setLauncherUnlockAnimationController(mLauncherUnlockAnimationController);
         new LinkedHashMap<>(mRemoteTransitions).forEach(this::registerRemoteTransition);
@@ -740,6 +743,28 @@
         mSplitScreenListener = null;
     }
 
+    public void registerSplitSelectListener(ISplitSelectListener listener) {
+        if (mSplitScreen != null) {
+            try {
+                mSplitScreen.registerSplitSelectListener(listener);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Failed call registerSplitSelectListener");
+            }
+        }
+        mSplitSelectListener = listener;
+    }
+
+    public void unregisterSplitSelectListener(ISplitSelectListener listener) {
+        if (mSplitScreen != null) {
+            try {
+                mSplitScreen.unregisterSplitSelectListener(listener);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Failed call unregisterSplitSelectListener");
+            }
+        }
+        mSplitSelectListener = null;
+    }
+
     /** Start multiple tasks in split-screen simultaneously. */
     public void startTasks(int taskId1, Bundle options1, int taskId2, Bundle options2,
             @SplitConfigurationOptions.StagePosition int splitPosition, float splitRatio,
@@ -1281,6 +1306,17 @@
         }
     }
 
+    /** Perform cleanup transactions after animation to split select is complete */
+    public void onDesktopSplitSelectAnimComplete(ActivityManager.RunningTaskInfo taskInfo) {
+        if (mDesktopMode != null) {
+            try {
+                mDesktopMode.onDesktopSplitSelectAnimComplete(taskInfo);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Failed call onDesktopSplitSelectAnimComplete", e);
+            }
+        }
+    }
+
     //
     // Unfold transition
     //
diff --git a/quickstep/src/com/android/quickstep/TouchInteractionService.java b/quickstep/src/com/android/quickstep/TouchInteractionService.java
index f1244ff..7a9f88a 100644
--- a/quickstep/src/com/android/quickstep/TouchInteractionService.java
+++ b/quickstep/src/com/android/quickstep/TouchInteractionService.java
@@ -56,6 +56,8 @@
 import android.app.PendingIntent;
 import android.app.RemoteAction;
 import android.app.Service;
+import android.content.IIntentReceiver;
+import android.content.IIntentSender;
 import android.content.Intent;
 import android.content.SharedPreferences;
 import android.content.res.Configuration;
@@ -571,15 +573,7 @@
         AccessibilityManager am = getSystemService(AccessibilityManager.class);
 
         if (isHomeAndOverviewSame) {
-            Intent intent = new Intent(mOverviewComponentObserver.getHomeIntent())
-                    .setAction(INTENT_ACTION_ALL_APPS_TOGGLE);
-            RemoteAction allAppsAction = new RemoteAction(
-                    Icon.createWithResource(this, R.drawable.ic_apps),
-                    getString(R.string.all_apps_label),
-                    getString(R.string.all_apps_label),
-                    PendingIntent.getActivity(this, GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS, intent,
-                            PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
-            am.registerSystemAction(allAppsAction, GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS);
+            am.registerSystemAction(createAllAppsAction(), GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS);
         } else {
             am.unregisterSystemAction(GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS);
         }
@@ -592,6 +586,35 @@
         mTISBinder.onOverviewTargetChange();
     }
 
+    private RemoteAction createAllAppsAction() {
+        final Intent homeIntent = new Intent(mOverviewComponentObserver.getHomeIntent())
+                .setAction(INTENT_ACTION_ALL_APPS_TOGGLE);
+        final PendingIntent actionPendingIntent;
+
+        if (FeatureFlags.ENABLE_ALL_APPS_SEARCH_IN_TASKBAR.get()) {
+            actionPendingIntent = new PendingIntent(new IIntentSender.Stub() {
+                @Override
+                public void send(int code, Intent intent, String resolvedType,
+                        IBinder allowlistToken, IIntentReceiver finishedReceiver,
+                        String requiredPermission, Bundle options) {
+                    MAIN_EXECUTOR.execute(() -> mTaskbarManager.toggleAllApps(homeIntent));
+                }
+            });
+        } else {
+            actionPendingIntent = PendingIntent.getActivity(
+                    this,
+                    GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS,
+                    homeIntent,
+                    PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+        }
+
+        return new RemoteAction(
+                Icon.createWithResource(this, R.drawable.ic_apps),
+                getString(R.string.all_apps_label),
+                getString(R.string.all_apps_label),
+                actionPendingIntent);
+    }
+
     @UiThread
     private void onSystemUiFlagsChanged(int lastSysUIFlags) {
         if (LockedUserState.get(this).isUserUnlocked()) {
diff --git a/quickstep/src/com/android/quickstep/interaction/AllSetActivity.java b/quickstep/src/com/android/quickstep/interaction/AllSetActivity.java
index f11bc81..49814df 100644
--- a/quickstep/src/com/android/quickstep/interaction/AllSetActivity.java
+++ b/quickstep/src/com/android/quickstep/interaction/AllSetActivity.java
@@ -95,9 +95,10 @@
 
     private static final float ANIMATION_PAUSE_ALPHA_THRESHOLD = 0.1f;
 
+    private final AnimatedFloat mSwipeProgress = new AnimatedFloat(this::onSwipeProgressUpdate);
+
     private TISBindHelper mTISBindHelper;
 
-    private final AnimatedFloat mSwipeProgress = new AnimatedFloat(this::onSwipeProgressUpdate);
     private BgDrawable mBackground;
     private View mRootView;
     private float mSwipeUpShift;
@@ -172,7 +173,7 @@
                         LOTTIE_TERTIARY_COLOR_TOKEN, R.color.all_set_bg_tertiary),
                 getTheme());
 
-        startBackgroundAnimation();
+        startBackgroundAnimation(dp.isTablet);
     }
 
     private void runOnUiHelperThread(Runnable runnable) {
@@ -183,7 +184,7 @@
         Executors.UI_HELPER_EXECUTOR.execute(runnable);
     }
 
-    private void startBackgroundAnimation() {
+    private void startBackgroundAnimation(boolean forTablet) {
         if (!Utilities.ATLEAST_S || mVibrator == null) {
             return;
         }
@@ -199,7 +200,7 @@
                     .addPrimitive(supportsThud
                                     ? VibrationEffect.Composition.PRIMITIVE_THUD
                                     : VibrationEffect.Composition.PRIMITIVE_TICK,
-                            /* scale= */ 1.0f,
+                            /* scale= */ forTablet ? 1.0f : 0.3f,
                             /* delay= */ 50)
                     .compose();
 
diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
index 453a1bd..5565139 100644
--- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
+++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java
@@ -17,10 +17,14 @@
 package com.android.quickstep.util;
 
 import static com.android.launcher3.Utilities.postAsyncCallback;
+import static com.android.launcher3.config.FeatureFlags.ENABLE_SPLIT_FROM_DESKTOP_TO_WORKSPACE;
+import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_DESKTOP_MODE_SPLIT_RIGHT_BOTTOM;
 import static com.android.launcher3.testing.shared.TestProtocol.LAUNCH_SPLIT_PAIR;
 import static com.android.launcher3.testing.shared.TestProtocol.testLogD;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
+import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
 import static com.android.launcher3.util.SplitConfigurationOptions.DEFAULT_SPLIT_RATIO;
+import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT;
 import static com.android.quickstep.util.SplitSelectDataHolder.SPLIT_PENDINGINTENT_PENDINGINTENT;
 import static com.android.quickstep.util.SplitSelectDataHolder.SPLIT_PENDINGINTENT_TASK;
 import static com.android.quickstep.util.SplitSelectDataHolder.SPLIT_SHORTCUT_TASK;
@@ -31,6 +35,8 @@
 import static com.android.quickstep.util.SplitSelectDataHolder.SPLIT_TASK_SHORTCUT;
 import static com.android.quickstep.util.SplitSelectDataHolder.SPLIT_TASK_TASK;
 
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
 import android.annotation.NonNull;
 import android.app.ActivityManager;
 import android.app.ActivityOptions;
@@ -38,11 +44,16 @@
 import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
+import android.content.pm.PackageManager;
 import android.content.pm.ShortcutInfo;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.IBinder;
 import android.os.RemoteException;
+import android.os.SystemClock;
 import android.os.UserHandle;
 import android.util.Log;
 import android.util.Pair;
@@ -57,7 +68,11 @@
 import androidx.annotation.Nullable;
 
 import com.android.internal.logging.InstanceId;
+import com.android.launcher3.Launcher;
+import com.android.launcher3.R;
+import com.android.launcher3.anim.PendingAnimation;
 import com.android.launcher3.config.FeatureFlags;
+import com.android.launcher3.icons.IconProvider;
 import com.android.launcher3.logging.StatsLogManager;
 import com.android.launcher3.model.data.ItemInfo;
 import com.android.launcher3.statehandlers.DepthController;
@@ -66,6 +81,11 @@
 import com.android.launcher3.testing.shared.TestProtocol;
 import com.android.launcher3.util.ComponentKey;
 import com.android.launcher3.util.SplitConfigurationOptions.StagePosition;
+import com.android.quickstep.OverviewComponentObserver;
+import com.android.quickstep.RecentsAnimationCallbacks;
+import com.android.quickstep.RecentsAnimationController;
+import com.android.quickstep.RecentsAnimationDeviceState;
+import com.android.quickstep.RecentsAnimationTargets;
 import com.android.quickstep.RecentsModel;
 import com.android.quickstep.SplitSelectionListener;
 import com.android.quickstep.SystemUiProxy;
@@ -74,8 +94,11 @@
 import com.android.quickstep.views.FloatingTaskView;
 import com.android.quickstep.views.GroupedTaskView;
 import com.android.quickstep.views.SplitInstructionsView;
+import com.android.quickstep.views.RecentsView;
 import com.android.systemui.shared.recents.model.Task;
+import com.android.systemui.shared.system.ActivityManagerWrapper;
 import com.android.systemui.shared.system.RemoteAnimationRunnerCompat;
+import com.android.wm.shell.splitscreen.ISplitSelectListener;
 
 import java.io.PrintWriter;
 import java.util.ArrayList;
@@ -99,6 +122,7 @@
     private final StatsLogManager mStatsLogManager;
     private final SystemUiProxy mSystemUiProxy;
     private final StateManager mStateManager;
+    private SplitFromDesktopController mSplitFromDesktopController;
     @Nullable
     private DepthController mDepthController;
     private boolean mRecentsAnimationRunning;
@@ -476,6 +500,14 @@
         }
     }
 
+    public void initSplitFromDesktopController(Launcher launcher) {
+        mSplitFromDesktopController = new SplitFromDesktopController(launcher);
+    }
+
+    public void enterSplitFromDesktop(ActivityManager.RunningTaskInfo taskInfo) {
+        mSplitFromDesktopController.enterSplitSelect(taskInfo);
+    }
+
     private RemoteTransition getShellRemoteTransition(int firstTaskId, int secondTaskId,
             @Nullable Consumer<Boolean> callback, String transitionName) {
         final RemoteSplitLaunchTransitionRunner animationRunner =
@@ -686,4 +718,114 @@
             mSplitSelectDataHolder.dump(prefix, writer);
         }
     }
+
+    public class SplitFromDesktopController {
+        private static final String TAG = "SplitFromDesktopController";
+
+        private final Launcher mLauncher;
+        private final OverviewComponentObserver mOverviewComponentObserver;
+        private final int mSplitPlaceholderSize;
+        private final int mSplitPlaceholderInset;
+        private ActivityManager.RunningTaskInfo mTaskInfo;
+        private ISplitSelectListener mSplitSelectListener;
+        private Drawable mAppIcon;
+
+        public SplitFromDesktopController(Launcher launcher) {
+            mLauncher = launcher;
+            RecentsAnimationDeviceState deviceState = new RecentsAnimationDeviceState(
+                    launcher.getApplicationContext());
+            mOverviewComponentObserver =
+                    new OverviewComponentObserver(launcher.getApplicationContext(), deviceState);
+            mSplitPlaceholderSize = mLauncher.getResources().getDimensionPixelSize(
+                    R.dimen.split_placeholder_size);
+            mSplitPlaceholderInset = mLauncher.getResources().getDimensionPixelSize(
+                    R.dimen.split_placeholder_inset);
+            mSplitSelectListener = new ISplitSelectListener.Stub() {
+                @Override
+                public boolean onRequestSplitSelect(ActivityManager.RunningTaskInfo taskInfo) {
+                    if (!ENABLE_SPLIT_FROM_DESKTOP_TO_WORKSPACE.get()) return false;
+                    MAIN_EXECUTOR.execute(() -> enterSplitSelect(taskInfo));
+                    return true;
+                }
+            };
+            SystemUiProxy.INSTANCE.get(mLauncher).registerSplitSelectListener(mSplitSelectListener);
+        }
+
+        /**
+         * Enter split select from desktop mode.
+         * @param taskInfo the desktop task to move to split stage
+         */
+        public void enterSplitSelect(ActivityManager.RunningTaskInfo taskInfo) {
+            mTaskInfo = taskInfo;
+            String packageName = mTaskInfo.realActivity.getPackageName();
+            PackageManager pm = mLauncher.getApplicationContext().getPackageManager();
+            IconProvider provider = new IconProvider(mLauncher.getApplicationContext());
+            try {
+                mAppIcon = provider.getIcon(pm.getActivityInfo(mTaskInfo.baseActivity,
+                     PackageManager.ComponentInfoFlags.of(0)));
+            } catch (PackageManager.NameNotFoundException e) {
+                Log.w(TAG, "Package not found: " + packageName, e);
+            }
+            RecentsAnimationCallbacks callbacks = new RecentsAnimationCallbacks(
+                    SystemUiProxy.INSTANCE.get(mLauncher.getApplicationContext()),
+                    false /* allowMinimizeSplitScreen */);
+
+            DesktopSplitRecentsAnimationListener listener =
+                    new DesktopSplitRecentsAnimationListener();
+
+            MAIN_EXECUTOR.execute(() -> {
+                callbacks.addListener(listener);
+                UI_HELPER_EXECUTOR.execute(
+                        // Transition from app to enter stage split in launcher with
+                        // recents animation.
+                        () -> ActivityManagerWrapper.getInstance().startRecentsActivity(
+                                mOverviewComponentObserver.getOverviewIntent(),
+                                SystemClock.uptimeMillis(), callbacks, null, null));
+            });
+        }
+
+        private class DesktopSplitRecentsAnimationListener implements
+                RecentsAnimationCallbacks.RecentsAnimationListener {
+            private final Rect mTempRect = new Rect();
+
+            @Override
+            public void onRecentsAnimationStart(RecentsAnimationController controller,
+                    RecentsAnimationTargets targets) {
+                setInitialTaskSelect(mTaskInfo, STAGE_POSITION_BOTTOM_OR_RIGHT,
+                        null, LAUNCHER_DESKTOP_MODE_SPLIT_RIGHT_BOTTOM);
+
+                RecentsView recentsView = mLauncher.getOverviewPanel();
+                recentsView.getPagedOrientationHandler().getInitialSplitPlaceholderBounds(
+                        mSplitPlaceholderSize, mSplitPlaceholderInset,
+                        mLauncher.getDeviceProfile(), getActiveSplitStagePosition(), mTempRect);
+
+                PendingAnimation anim = new PendingAnimation(
+                        SplitAnimationTimings.TABLET_HOME_TO_SPLIT.getDuration());
+                RectF startingTaskRect = new RectF(mTaskInfo.configuration.windowConfiguration
+                        .getBounds());
+                final FloatingTaskView floatingTaskView = FloatingTaskView.getFloatingTaskView(
+                        mLauncher, mLauncher.getDragLayer(),
+                        null /* thumbnail */,
+                        mAppIcon, new RectF());
+                floatingTaskView.setAlpha(1);
+                floatingTaskView.addStagingAnimation(anim, startingTaskRect, mTempRect,
+                        false /* fadeWithThumbnail */, true /* isStagedTask */);
+                setFirstFloatingTaskView(floatingTaskView);
+
+                anim.addListener(new AnimatorListenerAdapter() {
+                    @Override
+                    public void onAnimationStart(Animator animation) {
+                        controller.finish(true /* toRecents */, null /* onFinishComplete */,
+                                false /* sendUserLeaveHint */);
+                    }
+                    @Override
+                    public void onAnimationEnd(Animator animation) {
+                        SystemUiProxy.INSTANCE.get(mLauncher.getApplicationContext())
+                                .onDesktopSplitSelectAnimComplete(mTaskInfo);
+                    }
+                });
+                anim.buildAnim().start();
+            }
+        }
+    }
 }
diff --git a/quickstep/src/com/android/quickstep/util/SplitToWorkspaceController.java b/quickstep/src/com/android/quickstep/util/SplitToWorkspaceController.java
index b36cf5f..056f9aa 100644
--- a/quickstep/src/com/android/quickstep/util/SplitToWorkspaceController.java
+++ b/quickstep/src/com/android/quickstep/util/SplitToWorkspaceController.java
@@ -16,6 +16,7 @@
 
 package com.android.quickstep.util;
 
+import static com.android.launcher3.config.FeatureFlags.ENABLE_SPLIT_FROM_DESKTOP_TO_WORKSPACE;
 import static com.android.launcher3.config.FeatureFlags.ENABLE_SPLIT_FROM_FULLSCREEN_WITH_KEYBOARD_SHORTCUTS;
 import static com.android.launcher3.config.FeatureFlags.ENABLE_SPLIT_FROM_WORKSPACE_TO_WORKSPACE;
 
@@ -178,7 +179,8 @@
 
     private boolean shouldIgnoreSecondSplitLaunch() {
         return (!ENABLE_SPLIT_FROM_FULLSCREEN_WITH_KEYBOARD_SHORTCUTS.get()
-                && !ENABLE_SPLIT_FROM_WORKSPACE_TO_WORKSPACE.get())
+                && !ENABLE_SPLIT_FROM_WORKSPACE_TO_WORKSPACE.get()
+                && !ENABLE_SPLIT_FROM_DESKTOP_TO_WORKSPACE.get())
                 || !mController.isSplitSelectActive();
     }
 }
diff --git a/src/com/android/launcher3/AppWidgetResizeFrame.java b/src/com/android/launcher3/AppWidgetResizeFrame.java
index 9a1ccd0..7131452 100644
--- a/src/com/android/launcher3/AppWidgetResizeFrame.java
+++ b/src/com/android/launcher3/AppWidgetResizeFrame.java
@@ -360,6 +360,37 @@
             lp.y = sTmpRect.top;
         }
 
+        // Handle invalid resize across CellLayouts in the two panel UI.
+        if (mCellLayout.getParent() instanceof Workspace) {
+            Workspace<?> workspace = (Workspace<?>) mCellLayout.getParent();
+            CellLayout pairedCellLayout = workspace.getScreenPair(mCellLayout);
+            if (pairedCellLayout != null) {
+                Rect focusedCellLayoutBound = sTmpRect;
+                mDragLayerRelativeCoordinateHelper.viewToRect(mCellLayout, focusedCellLayoutBound);
+                Rect resizeFrameBound = sTmpRect2;
+                findViewById(R.id.widget_resize_frame).getGlobalVisibleRect(resizeFrameBound);
+                float progress = 1f;
+                if (workspace.indexOfChild(pairedCellLayout) < workspace.indexOfChild(mCellLayout)
+                        && mDeltaX < 0
+                        && resizeFrameBound.left < focusedCellLayoutBound.left) {
+                    // Resize from right to left.
+                    progress = (mDragAcrossTwoPanelOpacityMargin + mDeltaX)
+                            / mDragAcrossTwoPanelOpacityMargin;
+                } else if (workspace.indexOfChild(pairedCellLayout)
+                                > workspace.indexOfChild(mCellLayout)
+                        && mDeltaX > 0
+                        && resizeFrameBound.right > focusedCellLayoutBound.right) {
+                    // Resize from left to right.
+                    progress = (mDragAcrossTwoPanelOpacityMargin - mDeltaX)
+                            / mDragAcrossTwoPanelOpacityMargin;
+                }
+                float alpha = Math.max(MIN_OPACITY_FOR_CELL_LAYOUT_DURING_INVALID_RESIZE, progress);
+                float springLoadedProgress = Math.min(1f, 1f - progress);
+                updateInvalidResizeEffect(mCellLayout, pairedCellLayout, alpha,
+                        springLoadedProgress);
+            }
+        }
+
         requestLayout();
     }
 
@@ -516,6 +547,13 @@
         }
 
         final DragLayer.LayoutParams lp = (DragLayer.LayoutParams) getLayoutParams();
+        final CellLayout pairedCellLayout;
+        if (mCellLayout.getParent() instanceof Workspace) {
+            Workspace<?> workspace = (Workspace<?>) mCellLayout.getParent();
+            pairedCellLayout = workspace.getScreenPair(mCellLayout);
+        } else {
+            pairedCellLayout = null;
+        }
         if (!animate) {
             lp.width = newWidth;
             lp.height = newHeight;
@@ -524,6 +562,10 @@
             for (int i = 0; i < HANDLE_COUNT; i++) {
                 mDragHandles[i].setAlpha(1f);
             }
+            if (pairedCellLayout != null) {
+                updateInvalidResizeEffect(mCellLayout, pairedCellLayout, /* alpha= */ 1f,
+                        /* springLoadedProgress= */ 0f);
+            }
             requestLayout();
         } else {
             ObjectAnimator oa = ObjectAnimator.ofPropertyValuesHolder(lp,
@@ -539,6 +581,10 @@
                 set.play(mFirstFrameAnimatorHelper.addTo(
                         ObjectAnimator.ofFloat(mDragHandles[i], ALPHA, 1f)));
             }
+            if (pairedCellLayout != null) {
+                updateInvalidResizeEffect(mCellLayout, pairedCellLayout, /* alpha= */ 1f,
+                        /* springLoadedProgress= */ 0f, /* animatorSet= */ set);
+            }
             set.setDuration(SNAP_DURATION);
             set.start();
         }
diff --git a/src/com/android/launcher3/DeviceProfile.java b/src/com/android/launcher3/DeviceProfile.java
index ee7bebf..7ece9a4 100644
--- a/src/com/android/launcher3/DeviceProfile.java
+++ b/src/com/android/launcher3/DeviceProfile.java
@@ -632,6 +632,10 @@
         // Hotseat and QSB width depends on updated cellSize and workspace padding
         recalculateHotseatWidthAndBorderSpace();
 
+        if (mIsResponsiveGrid && isVerticalBarLayout()) {
+            hotseatBorderSpace = cellLayoutBorderSpacePx.y;
+        }
+
         // AllApps height calculation depends on updated cellSize
         if (isTablet) {
             int collapseHandleHeight =
@@ -731,7 +735,7 @@
     /** Updates hotseatCellHeightPx and hotseatBarSizePx */
     private void updateHotseatSizes(int hotseatIconSizePx) {
         // Ensure there is enough space for folder icons, which have a slightly larger radius.
-        hotseatCellHeightPx = (int) Math.ceil(hotseatIconSizePx * ICON_OVERLAP_FACTOR);
+        hotseatCellHeightPx = getIconSizeWithOverlap(hotseatIconSizePx);
 
         if (isVerticalBarLayout()) {
             hotseatBarSizePx = hotseatIconSizePx + hotseatBarSidePaddingStartPx
@@ -797,7 +801,6 @@
             hotseatBorderSpace = calculateHotseatBorderSpace(maxHotseatIconsWidthPx,
                     (isQsbInline ? 1 : 0) + /* border between nav buttons and first icon */ 1);
         } while (hotseatBorderSpace < mMinHotseatIconSpacePx && numShownHotseatIcons > 1);
-
     }
 
     private Point getCellLayoutBorderSpace(InvariantDeviceProfile idp) {
@@ -880,11 +883,24 @@
         float workspaceCellPaddingY = getCellSize().y - iconSizePx - iconDrawablePaddingPx
                 - iconTextHeight;
 
+        if (mIsResponsiveGrid) {
+            // Hide text only if doesn't fit inside the cell for responsive grid
+            if (workspaceCellPaddingY < 0) {
+                iconTextSizePx = 0;
+                iconDrawablePaddingPx = 0;
+                int iconSizeWithOverlap = getIconSizeWithOverlap(iconSizePx);
+                cellYPaddingPx = Math.max(0, getCellSize().y - iconSizeWithOverlap) / 2;
+                autoResizeAllAppsCells();
+            }
+
+            return;
+        }
+
         // We want enough space so that the text is closer to its corresponding icon.
         if (workspaceCellPaddingY < iconTextHeight) {
             iconTextSizePx = 0;
             iconDrawablePaddingPx = 0;
-            cellHeightPx = (int) Math.ceil(iconSizePx * ICON_OVERLAP_FACTOR);
+            cellHeightPx = getIconSizeWithOverlap(iconSizePx);
             autoResizeAllAppsCells();
         }
     }
@@ -964,6 +980,10 @@
         return Math.max(0, drawablePadding - iconSizeDiff / 2);
     }
 
+    private int getIconSizeWithOverlap(int iconSize) {
+        return (int) Math.ceil(iconSize * ICON_OVERLAP_FACTOR);
+    }
+
     /**
      * Updating the iconSize affects many aspects of the launcher layout, such as: iconSizePx,
      * iconTextSizePx, iconDrawablePaddingPx, cellWidth/Height, allApps* variants,
@@ -1066,7 +1086,7 @@
         } else {
             iconDrawablePaddingPx = (int) (getNormalizedIconDrawablePadding() * iconScale);
             cellWidthPx = iconSizePx + iconDrawablePaddingPx;
-            cellHeightPx = (int) Math.ceil(iconSizePx * ICON_OVERLAP_FACTOR)
+            cellHeightPx = getIconSizeWithOverlap(iconSizePx)
                     + iconDrawablePaddingPx
                     + Utilities.calculateTextHeight(iconTextSizePx);
             int cellPaddingY = (getCellSize().y - cellHeightPx) / 2;
@@ -1121,7 +1141,6 @@
         return Math.min(hotseatBorderSpacePx, mMaxHotseatIconSpacePx);
     }
 
-
     /**
      * Updates the iconSize for allApps* variants.
      */
@@ -1469,14 +1488,26 @@
     private void updateWorkspacePadding() {
         Rect padding = workspacePadding;
         if (isVerticalBarLayout()) {
-            padding.top = 0;
-            padding.bottom = edgeMarginPx;
-            if (isSeascape()) {
-                padding.left = hotseatBarSizePx;
-                padding.right = hotseatBarSidePaddingStartPx;
+            if (mIsResponsiveGrid) {
+                padding.top = mResponsiveHeightSpec.getStartPaddingPx();
+                padding.bottom = mResponsiveHeightSpec.getEndPaddingPx();
+                if (isSeascape()) {
+                    padding.left = hotseatBarSizePx + mResponsiveWidthSpec.getEndPaddingPx();
+                    padding.right = mResponsiveWidthSpec.getStartPaddingPx();
+                } else {
+                    padding.left = mResponsiveWidthSpec.getStartPaddingPx();
+                    padding.right = hotseatBarSizePx + mResponsiveWidthSpec.getEndPaddingPx();
+                }
             } else {
-                padding.left = hotseatBarSidePaddingStartPx;
-                padding.right = hotseatBarSizePx;
+                padding.top = 0;
+                padding.bottom = edgeMarginPx;
+                if (isSeascape()) {
+                    padding.left = hotseatBarSizePx;
+                    padding.right = hotseatBarSidePaddingStartPx;
+                } else {
+                    padding.left = hotseatBarSidePaddingStartPx;
+                    padding.right = hotseatBarSizePx;
+                }
             }
         } else {
             // Pad the bottom of the workspace with hotseat bar
@@ -1519,7 +1550,9 @@
             // in vertical bar layout.
             // Workspace icons are moved up by a small factor. The variable diffOverlapFactor
             // is set to account for that difference.
-            float diffOverlapFactor = iconSizePx * (ICON_OVERLAP_FACTOR - 1) / 2;
+            float diffOverlapFactor = mIsResponsiveGrid ? 0
+                    : iconSizePx * (ICON_OVERLAP_FACTOR - 1) / 2;
+
             int paddingTop = Math.max((int) (mInsets.top + cellLayoutPaddingPx.top
                     - diffOverlapFactor), 0);
             int paddingBottom = Math.max((int) (mInsets.bottom + cellLayoutPaddingPx.bottom
diff --git a/src/com/android/launcher3/InvariantDeviceProfile.java b/src/com/android/launcher3/InvariantDeviceProfile.java
index 722991a..be14844 100644
--- a/src/com/android/launcher3/InvariantDeviceProfile.java
+++ b/src/com/android/launcher3/InvariantDeviceProfile.java
@@ -18,6 +18,7 @@
 
 import static com.android.launcher3.LauncherPrefs.GRID_NAME;
 import static com.android.launcher3.Utilities.dpiFromPx;
+import static com.android.launcher3.config.FeatureFlags.ENABLE_TWO_PANEL_HOME;
 import static com.android.launcher3.testing.shared.ResourceUtils.INVALID_RESOURCE_HANDLE;
 import static com.android.launcher3.util.DisplayController.CHANGE_DENSITY;
 import static com.android.launcher3.util.DisplayController.CHANGE_NAVIGATION_MODE;
@@ -313,7 +314,7 @@
         int type = displayInfo.supportedBounds.stream()
                 .mapToInt(bounds -> displayInfo.isTablet(bounds) ? flagTablet : flagPhone)
                 .reduce(0, (a, b) -> a | b);
-        if ((type == (flagPhone | flagTablet))) {
+        if ((type == (flagPhone | flagTablet)) && ENABLE_TWO_PANEL_HOME.get()) {
             // device has profiles supporting both phone and table modes
             return TYPE_MULTI_DISPLAY;
         } else if (type == flagTablet) {
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index c737074..d0a2576 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -44,6 +44,7 @@
 import static com.android.launcher3.LauncherState.SPRING_LOADED;
 import static com.android.launcher3.Utilities.postAsyncCallback;
 import static com.android.launcher3.accessibility.LauncherAccessibilityDelegate.getSupportedActions;
+import static com.android.launcher3.config.FeatureFlags.FOLDABLE_SINGLE_PAGE;
 import static com.android.launcher3.config.FeatureFlags.MULTI_SELECT_EDIT_MODE;
 import static com.android.launcher3.config.FeatureFlags.SHOW_DOT_PAGINATION;
 import static com.android.launcher3.logging.StatsLogManager.EventEnum;
@@ -766,7 +767,7 @@
         }
 
         onDeviceProfileInitiated();
-        if (mDeviceProfile.isTwoPanels) {
+        if (FOLDABLE_SINGLE_PAGE.get() && mDeviceProfile.isTwoPanels) {
             mCellPosMapper = new TwoPanelCellPosMapper(mDeviceProfile.inv.numColumns);
         } else {
             mCellPosMapper = CellPosMapper.DEFAULT;
diff --git a/src/com/android/launcher3/Workspace.java b/src/com/android/launcher3/Workspace.java
index b141e18..adaf20f 100644
--- a/src/com/android/launcher3/Workspace.java
+++ b/src/com/android/launcher3/Workspace.java
@@ -28,6 +28,7 @@
 import static com.android.launcher3.LauncherState.SPRING_LOADED;
 import static com.android.launcher3.MotionEventsUtils.isTrackpadMultiFingerSwipe;
 import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback;
+import static com.android.launcher3.config.FeatureFlags.FOLDABLE_SINGLE_PAGE;
 import static com.android.launcher3.logging.StatsLogManager.LAUNCHER_STATE_HOME;
 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_SWIPELEFT;
 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_SWIPERIGHT;
@@ -126,6 +127,7 @@
 import com.android.systemui.plugins.shared.LauncherOverlayManager.LauncherOverlayCallbacks;
 
 import java.util.ArrayList;
+import java.util.Iterator;
 import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Predicate;
@@ -501,14 +503,19 @@
                 .log(LauncherEvent.LAUNCHER_ITEM_DRAG_STARTED);
     }
 
-    public void deferRemoveExtraEmptyScreen() {
-        mDeferRemoveExtraEmptyScreen = true;
+    private boolean isTwoPanelEnabled() {
+        return !FOLDABLE_SINGLE_PAGE.get() && mLauncher.mDeviceProfile.isTwoPanels;
     }
 
     @Override
     public int getPanelCount() {
-        return super.getPanelCount();
+        return isTwoPanelEnabled() ? 2 : super.getPanelCount();
     }
+
+    public void deferRemoveExtraEmptyScreen() {
+        mDeferRemoveExtraEmptyScreen = true;
+    }
+
     @Override
     public void onDragEnd() {
         if (ENFORCE_DRAG_EVENT_ORDER) {
@@ -661,7 +668,7 @@
         // created CellLayout.
         DeviceProfile dp = mLauncher.getDeviceProfile();
         CellLayout newScreen;
-        if (dp.isTwoPanels) {
+        if (FOLDABLE_SINGLE_PAGE.get() && dp.isTwoPanels) {
             newScreen = (CellLayout) LayoutInflater.from(getContext()).inflate(
                     R.layout.workspace_screen_foldable, this, false /* attachToRoot */);
         } else {
@@ -686,6 +693,15 @@
 
         if (mDragSourceInternal != null) {
             int dragSourceChildCount = mDragSourceInternal.getChildCount();
+
+            // If the icon was dragged from Hotseat, there is no page pair
+            if (isTwoPanelEnabled() && !(mDragSourceInternal.getParent() instanceof Hotseat)) {
+                int pagePairScreenId = getScreenPair(getCellPosMapper().mapModelToPresenter(
+                        dragObject.dragInfo).screenId);
+                CellLayout pagePair = mWorkspaceScreens.get(pagePairScreenId);
+                dragSourceChildCount += pagePair.getShortcutsAndWidgets().getChildCount();
+            }
+
             // When the drag view content is a LauncherAppWidgetHostView, we should increment the
             // drag source child count by 1 because the widget in drag has been detached from its
             // original parent, ShortcutAndWidgetContainer, and reattached to the DragView.
@@ -696,6 +712,11 @@
             if (dragSourceChildCount == 1) {
                 lastChildOnScreen = true;
             }
+            CellLayout cl = (CellLayout) mDragSourceInternal.getParent();
+            if (!FOLDABLE_SINGLE_PAGE.get() && getLeftmostVisiblePageForIndex(indexOfChild(cl))
+                    == getLeftmostVisiblePageForIndex(getPageCount() - 1)) {
+                childOnFinalScreen = true;
+            }
         }
 
         // If this is the last item on the final screen
@@ -730,6 +751,9 @@
      */
     private void forEachExtraEmptyPageId(Consumer<Integer> callback) {
         callback.accept(EXTRA_EMPTY_SCREEN_ID);
+        if (isTwoPanelEnabled()) {
+            callback.accept(EXTRA_EMPTY_SCREEN_SECOND_ID);
+        }
     }
 
     /**
@@ -843,7 +867,9 @@
 
     public boolean hasExtraEmptyScreens() {
         return mWorkspaceScreens.containsKey(EXTRA_EMPTY_SCREEN_ID)
-                && getChildCount() > getPanelCount();
+                && getChildCount() > getPanelCount()
+                && (!isTwoPanelEnabled()
+                || mWorkspaceScreens.containsKey(EXTRA_EMPTY_SCREEN_SECOND_ID));
     }
 
     /**
@@ -949,7 +975,14 @@
      */
     @Nullable
     public CellLayout getScreenPair(CellLayout cellLayout) {
-        return null;
+        if (!isTwoPanelEnabled()) {
+            return null;
+        }
+        int screenId = getIdForScreen(cellLayout);
+        if (screenId == -1) {
+            return null;
+        }
+        return getScreenWithId(getScreenPair(screenId));
     }
 
     public void stripEmptyScreens() {
@@ -977,6 +1010,22 @@
             }
         }
 
+        // When two panel home is enabled we only remove an empty page if both visible pages are
+        // empty.
+        if (isTwoPanelEnabled()) {
+            // We go through all the pages that were marked as removable and check their page pair
+            Iterator<Integer> removeScreensIterator = removeScreens.iterator();
+            while (removeScreensIterator.hasNext()) {
+                int pageToRemove = removeScreensIterator.next();
+                int pagePair = getScreenPair(pageToRemove);
+                if (!removeScreens.contains(pagePair)) {
+                    // The page pair isn't empty so we want to remove the current page from the
+                    // removable pages' collection
+                    removeScreensIterator.remove();
+                }
+            }
+        }
+
         // We enforce at least one page (two pages on two panel home) to add new items to.
         // In the case that we remove the last such screen(s), we convert the last screen(s)
         // to the empty screen(s)
@@ -997,7 +1046,12 @@
                 removeView(cl);
             } else {
                 // The last page(s) should be converted into extra empty page(s)
-                int extraScreenId = EXTRA_EMPTY_SCREEN_ID;
+                int extraScreenId = isTwoPanelEnabled() && id % 2 == 1
+                        // This is the right panel in a two panel scenario
+                        ? EXTRA_EMPTY_SCREEN_SECOND_ID
+                        // This is either the last screen in a one panel scenario, or the left panel
+                        // in a two panel scenario when there are only two empty pages left
+                        : EXTRA_EMPTY_SCREEN_ID;
                 mWorkspaceScreens.put(extraScreenId, cl);
                 mScreenOrder.add(extraScreenId);
             }
@@ -2518,7 +2572,8 @@
         // Go through the pages and check if the dragged item is inside one of them. This block
         // is responsible for determining whether we need to snap to a different screen.
         int nextPage = getNextPage();
-        IntSet pageIndexesToVerify = IntSet.wrap(nextPage - 1, nextPage + 1);
+        IntSet pageIndexesToVerify = IntSet.wrap(nextPage - 1,
+                nextPage + (isTwoPanelEnabled() ? 2 : 1));
 
         for (int pageIndex : pageIndexesToVerify) {
             // When deciding whether to perform a page switch, we need to consider the most
diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java
index 273d505..83431dc 100644
--- a/src/com/android/launcher3/config/FeatureFlags.java
+++ b/src/com/android/launcher3/config/FeatureFlags.java
@@ -183,6 +183,17 @@
             "Enables predictive back animation from all apps and widgets to home");
 
     // TODO(Block 11): Clean up flags
+    public static final BooleanFlag ENABLE_TWO_PANEL_HOME = getDebugFlag(270392643,
+            "ENABLE_TWO_PANEL_HOME", ENABLED,
+            "Uses two panel on home screen. Only applicable on large screen devices.");
+
+    public static final BooleanFlag FOLDABLE_WORKSPACE_REORDER = getDebugFlag(270395070,
+            "FOLDABLE_WORKSPACE_REORDER", DISABLED,
+            "In foldables, when reordering the icons and widgets, is now going to use both sides");
+
+    public static final BooleanFlag FOLDABLE_SINGLE_PAGE = getDebugFlag(270395274,
+            "FOLDABLE_SINGLE_PAGE", ENABLED, "Use a single page for the workspace");
+
     public static final BooleanFlag ENABLE_PARAMETRIZE_REORDER = getDebugFlag(289420844,
             "ENABLE_PARAMETRIZE_REORDER", DISABLED,
             "Enables generating the reorder using a set of parameters");
@@ -245,7 +256,7 @@
             "Inject fallback app corpus result when AiAi fails to return it.");
 
     public static final BooleanFlag ENABLE_LONG_PRESS_NAV_HANDLE =
-            getDebugFlag(282993230, "ENABLE_LONG_PRESS_NAV_HANDLE", DISABLED,
+            getReleaseFlag(282993230, "ENABLE_LONG_PRESS_NAV_HANDLE", TEAMFOOD,
                     "Enables long pressing on the bottom bar nav handle to trigger events.");
 
     // TODO(Block 17): Clean up flags
@@ -370,6 +381,10 @@
             270393453, "ENABLE_SPLIT_FROM_WORKSPACE_TO_WORKSPACE", TEAMFOOD,
             "Enable initiating split screen from workspace to workspace.");
 
+    public static final BooleanFlag ENABLE_SPLIT_FROM_DESKTOP_TO_WORKSPACE = getDebugFlag(
+            279586624, "ENABLE_SPLIT_FROM_DESKTOP_TO_WORKSPACE", DISABLED,
+            "Enable initiating split screen from desktop mode to workspace.");
+
     public static final BooleanFlag ENABLE_TRACKPAD_GESTURE = getDebugFlag(271010401,
             "ENABLE_TRACKPAD_GESTURE", ENABLED, "Enables trackpad gesture.");
 
diff --git a/src/com/android/launcher3/logging/StatsLogManager.java b/src/com/android/launcher3/logging/StatsLogManager.java
index 8bb06c1..780cb5e 100644
--- a/src/com/android/launcher3/logging/StatsLogManager.java
+++ b/src/com/android/launcher3/logging/StatsLogManager.java
@@ -621,6 +621,9 @@
         @UiEvent(doc = "User has invoked split to left half with a keyboard shortcut.")
         LAUNCHER_KEYBOARD_SHORTCUT_SPLIT_LEFT_TOP(1233),
 
+        @UiEvent(doc = "User has invoked split to right half with desktop mode app icon")
+        LAUNCHER_DESKTOP_MODE_SPLIT_RIGHT_BOTTOM(1412),
+
         @UiEvent(doc = "User has collapsed the work FAB button by scrolling down in the all apps"
                 + " work A-Z list.")
         LAUNCHER_WORK_FAB_BUTTON_COLLAPSE(1276),
diff --git a/src/com/android/launcher3/testing/TestInformationHandler.java b/src/com/android/launcher3/testing/TestInformationHandler.java
index 438a4a0..5306932 100644
--- a/src/com/android/launcher3/testing/TestInformationHandler.java
+++ b/src/com/android/launcher3/testing/TestInformationHandler.java
@@ -17,6 +17,7 @@
 
 import static com.android.launcher3.allapps.AllAppsStore.DEFER_UPDATES_TEST;
 import static com.android.launcher3.config.FeatureFlags.ENABLE_TRACKPAD_GESTURE;
+import static com.android.launcher3.config.FeatureFlags.FOLDABLE_SINGLE_PAGE;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 
 import android.annotation.TargetApi;
@@ -156,7 +157,8 @@
                 return response;
 
             case TestProtocol.REQUEST_IS_TWO_PANELS:
-                response.putBoolean(TestProtocol.TEST_INFO_RESPONSE_FIELD, false);
+                response.putBoolean(TestProtocol.TEST_INFO_RESPONSE_FIELD,
+                        FOLDABLE_SINGLE_PAGE.get() ? false : mDeviceProfile.isTwoPanels);
                 return response;
 
             case TestProtocol.REQUEST_GET_HAD_NONTEST_EVENTS:
diff --git a/src/com/android/launcher3/views/AbstractSlideInView.java b/src/com/android/launcher3/views/AbstractSlideInView.java
index f69d299..30e0971 100644
--- a/src/com/android/launcher3/views/AbstractSlideInView.java
+++ b/src/com/android/launcher3/views/AbstractSlideInView.java
@@ -17,6 +17,7 @@
 
 import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
 
+import static com.android.app.animation.Interpolators.LINEAR;
 import static com.android.app.animation.Interpolators.scrollInterpolatorForVelocity;
 import static com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY;
 import static com.android.launcher3.LauncherAnimUtils.SUCCESS_TRANSITION_PROGRESS;
@@ -24,16 +25,14 @@
 import static com.android.launcher3.allapps.AllAppsTransitionController.REVERT_SWIPE_ALL_APPS_TO_HOME_ANIMATION_DURATION_MS;
 import static com.android.launcher3.util.ScrollableLayoutManager.PREDICTIVE_BACK_MIN_SCALE;
 
-import android.animation.AnimatorSet;
-import android.animation.ObjectAnimator;
-import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
 import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.Outline;
 import android.graphics.drawable.Drawable;
 import android.os.Build;
 import android.util.AttributeSet;
-import android.util.Property;
+import android.util.FloatProperty;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
@@ -51,6 +50,8 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.anim.AnimatedFloat;
 import com.android.launcher3.anim.AnimatorListeners;
+import com.android.launcher3.anim.AnimatorPlaybackController;
+import com.android.launcher3.anim.PendingAnimation;
 import com.android.launcher3.touch.BaseSwipeDetector;
 import com.android.launcher3.touch.SingleAxisSwipeDetector;
 
@@ -66,8 +67,8 @@
 public abstract class AbstractSlideInView<T extends Context & ActivityContext>
         extends AbstractFloatingView implements SingleAxisSwipeDetector.Listener {
 
-    protected static final Property<AbstractSlideInView, Float> TRANSLATION_SHIFT =
-            new Property<AbstractSlideInView, Float>(Float.class, "translationShift") {
+    protected static final FloatProperty<AbstractSlideInView<?>> TRANSLATION_SHIFT =
+            new FloatProperty<>("translationShift") {
 
                 @Override
                 public Float get(AbstractSlideInView view) {
@@ -75,31 +76,54 @@
                 }
 
                 @Override
-                public void set(AbstractSlideInView view, Float value) {
+                public void setValue(AbstractSlideInView view, float value) {
                     view.setTranslationShift(value);
                 }
             };
     protected static final float TRANSLATION_SHIFT_CLOSED = 1f;
     protected static final float TRANSLATION_SHIFT_OPENED = 0f;
     private static final float VIEW_NO_SCALE = 1f;
-    private static final int NO_DURATION = -1;
+    private static final int DEFAULT_DURATION = 300;
 
     protected final T mActivityContext;
 
     protected final SingleAxisSwipeDetector mSwipeDetector;
-    protected @NonNull AnimatorSet mOpenCloseAnimator;
-    private final ObjectAnimator mTranslationShiftAnimator;
+    protected @NonNull AnimatorPlaybackController mOpenCloseAnimation;
 
     protected ViewGroup mContent;
     protected final View mColorScrim;
 
+    /**
+     * Interpolator for {@link #mOpenCloseAnimation} when we are closing due to dragging downwards.
+     */
     private Interpolator mScrollInterpolator;
     private long mScrollDuration;
+    /**
+     * End progress for {@link #mOpenCloseAnimation} when we are closing due to dragging downloads.
+     * <p>
+     * There are two cases that determine this value:
+     * <ol>
+     *     <li>
+     *         If the drag interrupts the opening transition (i.e. {@link #mToTranslationShift}
+     *         is {@link #TRANSLATION_SHIFT_OPENED}), we need to animate back to {@code 0} to
+     *         reverse the animation that was paused at {@link #onDragStart(boolean, float)}.
+     *     </li>
+     *     <li>
+     *         If the drag started after the view is fully opened (i.e.
+     *         {@link #mToTranslationShift} is {@link #TRANSLATION_SHIFT_CLOSED}), the animation
+     *         that was set up at {@link #onDragStart(boolean, float)} for closing the view
+     *         should go forward to {@code 1}.
+     *     </li>
+     * </ol>
+     */
+    private float mScrollEndProgress;
 
     // range [0, 1], 0=> completely open, 1=> completely closed
     protected float mTranslationShift = TRANSLATION_SHIFT_CLOSED;
-    /** {@link #mTranslationShift} at the invocation of {@link #onDragStart(boolean, float)}. */
-    protected float mDragStartTranslationShift;
+    protected float mFromTranslationShift;
+    protected float mToTranslationShift;
+    /** {@link #mOpenCloseAnimation} progress at {@link #onDragStart(boolean, float)}. */
+    private float mDragStartProgress;
 
     protected boolean mNoIntercept;
     protected @Nullable OnCloseListener mOnCloseBeginListener;
@@ -128,52 +152,78 @@
         mActivityContext = ActivityContext.lookupContext(context);
 
         mScrollInterpolator = Interpolators.SCROLL_CUBIC;
-        mScrollDuration = NO_DURATION;
+        mScrollDuration = DEFAULT_DURATION;
         mSwipeDetector = new SingleAxisSwipeDetector(context, this,
                 SingleAxisSwipeDetector.VERTICAL);
 
-        mOpenCloseAnimator = new AnimatorSet();
-        mTranslationShiftAnimator = ObjectAnimator.ofPropertyValuesHolder(this);
+        mOpenCloseAnimation = new PendingAnimation(0).createPlaybackController();
 
         int scrimColor = getScrimColor(context);
         mColorScrim = scrimColor != -1 ? createColorScrim(context, scrimColor) : null;
     }
 
     /**
-     * Sets up a {@link #mOpenCloseAnimator} for opening with default parameters.
+     * Sets up a {@link #mOpenCloseAnimation} for opening with default parameters.
      *
-     * @see #setUpOpenCloseAnimator(float, Interpolator)
+     * @see #setUpOpenCloseAnimation(float, float, long)
      */
-    protected final AnimatorSet setUpDefaultOpenAnimator() {
-        return setUpOpenCloseAnimator(TRANSLATION_SHIFT_OPENED, Interpolators.FAST_OUT_SLOW_IN);
+    protected final AnimatorPlaybackController setUpDefaultOpenAnimation() {
+        AnimatorPlaybackController animation = setUpOpenCloseAnimation(
+                TRANSLATION_SHIFT_CLOSED, TRANSLATION_SHIFT_OPENED, DEFAULT_DURATION);
+        animation.getAnimationPlayer().setInterpolator(Interpolators.FAST_OUT_SLOW_IN);
+        return animation;
     }
 
     /**
-     * Initializes a new {@link #mOpenCloseAnimator}.
-     * <p>
-     * Subclasses should override this method if they want to add more {@code Animator} instances
-     * to the set.
+     * Sets up a {@link #mOpenCloseAnimation} for opening with a given duration.
      *
-     * @param translationShift             translation shift to animate to.
-     * @param translationShiftInterpolator interpolator for {@link #mTranslationShiftAnimator}.
-     * @return {@link #mOpenCloseAnimator}
+     * @see #setUpOpenCloseAnimation(float, float, long)
      */
-    protected AnimatorSet setUpOpenCloseAnimator(
-            float translationShift, Interpolator translationShiftInterpolator) {
-        mOpenCloseAnimator = new AnimatorSet();
-        mOpenCloseAnimator.addListener(AnimatorListeners.forEndCallback(() -> {
+    protected final AnimatorPlaybackController setUpOpenAnimation(long duration) {
+        return setUpOpenCloseAnimation(
+                TRANSLATION_SHIFT_CLOSED, TRANSLATION_SHIFT_OPENED, duration);
+    }
+
+    private AnimatorPlaybackController setUpCloseAnimation(long duration) {
+        return setUpOpenCloseAnimation(
+                TRANSLATION_SHIFT_OPENED, TRANSLATION_SHIFT_CLOSED, duration);
+    }
+
+    /**
+     * Initializes a new {@link #mOpenCloseAnimation}.
+     *
+     * @param fromTranslationShift translation shift to animate from.
+     * @param toTranslationShift   translation shift to animate to.
+     * @param duration             animation duration.
+     * @return {@link #mOpenCloseAnimation}
+     */
+    private AnimatorPlaybackController setUpOpenCloseAnimation(
+            float fromTranslationShift, float toTranslationShift, long duration) {
+        mFromTranslationShift = fromTranslationShift;
+        mToTranslationShift = toTranslationShift;
+
+        PendingAnimation animation = new PendingAnimation(duration);
+        animation.addEndListener(b -> {
             mSwipeDetector.finishedScrolling();
             announceAccessibilityChanges();
-        }));
+        });
 
-        mTranslationShiftAnimator.setValues(PropertyValuesHolder.ofFloat(
-                TRANSLATION_SHIFT, translationShift));
-        mTranslationShiftAnimator.setInterpolator(translationShiftInterpolator);
-        mOpenCloseAnimator.play(mTranslationShiftAnimator);
+        animation.addFloat(
+                this, TRANSLATION_SHIFT, fromTranslationShift, toTranslationShift, LINEAR);
+        onOpenCloseAnimationPending(animation);
 
-        return mOpenCloseAnimator;
+        mOpenCloseAnimation = animation.createPlaybackController();
+        return mOpenCloseAnimation;
     }
 
+    /**
+     * Invoked when a {@link #mOpenCloseAnimation} is being set up.
+     * <p>
+     * Subclasses can override this method to modify the animation before it's used to create a
+     * {@link AnimatorPlaybackController}.
+     */
+    protected void onOpenCloseAnimationPending(PendingAnimation animation) {}
+
     protected void attachToContainer() {
         if (mColorScrim != null) {
             getPopupContainer().addView(mColorScrim);
@@ -316,29 +366,33 @@
     }
 
     private boolean isOpeningAnimationRunning() {
-        return mIsOpen && mOpenCloseAnimator.isRunning();
+        return mIsOpen && mOpenCloseAnimation.getAnimationPlayer().isRunning();
     }
 
     /* SingleAxisSwipeDetector.Listener */
 
     @Override
     public void onDragStart(boolean start, float startDisplacement) {
-        mOpenCloseAnimator.cancel();
-        mDragStartTranslationShift = mTranslationShift;
+        if (mOpenCloseAnimation.getAnimationPlayer().isRunning()) {
+            mOpenCloseAnimation.pause();
+            mDragStartProgress = mOpenCloseAnimation.getProgressFraction();
+        } else {
+            setUpCloseAnimation(DEFAULT_DURATION);
+            mDragStartProgress = 0;
+        }
     }
 
     @Override
     public boolean onDrag(float displacement) {
-        setTranslationShift(Utilities.boundToRange(
-                mDragStartTranslationShift + displacement / getShiftRange(),
-                TRANSLATION_SHIFT_OPENED,
-                TRANSLATION_SHIFT_CLOSED));
+        float progress = mDragStartProgress
+                + Math.signum(mToTranslationShift - mFromTranslationShift)
+                * (displacement / getShiftRange());
+        mOpenCloseAnimation.setPlayFraction(Utilities.boundToRange(progress, 0, 1));
         return true;
     }
 
     @Override
     public void onDragEnd(float velocity) {
-        mDragStartTranslationShift = 0;
         float successfulShiftThreshold = mActivityContext.getDeviceProfile().isTablet
                 ? TABLET_BOTTOM_SHEET_SUCCESS_TRANSITION_PROGRESS : SUCCESS_TRANSITION_PROGRESS;
         if ((mSwipeDetector.isFling(velocity) && velocity > 0)
@@ -346,10 +400,15 @@
             mScrollInterpolator = scrollInterpolatorForVelocity(velocity);
             mScrollDuration = BaseSwipeDetector.calculateDuration(
                     velocity, TRANSLATION_SHIFT_CLOSED - mTranslationShift);
+            mScrollEndProgress = mToTranslationShift == TRANSLATION_SHIFT_OPENED ? 0 : 1;
             close(true);
         } else {
-            setUpOpenCloseAnimator(TRANSLATION_SHIFT_OPENED, Interpolators.DECELERATE)
-                    .setDuration(BaseSwipeDetector.calculateDuration(velocity, mTranslationShift))
+            ValueAnimator animator = mOpenCloseAnimation.getAnimationPlayer();
+            animator.setInterpolator(Interpolators.DECELERATE);
+            animator.setFloatValues(
+                    mOpenCloseAnimation.getProgressFraction(),
+                    mToTranslationShift == TRANSLATION_SHIFT_OPENED ? 1 : 0);
+            animator.setDuration(BaseSwipeDetector.calculateDuration(velocity, mTranslationShift))
                     .start();
         }
     }
@@ -371,24 +430,27 @@
         Optional.ofNullable(mOnCloseBeginListener).ifPresent(OnCloseListener::onSlideInViewClosed);
 
         if (!animate) {
-            mOpenCloseAnimator.cancel();
+            mOpenCloseAnimation.pause();
             setTranslationShift(TRANSLATION_SHIFT_CLOSED);
             onCloseComplete();
             return;
         }
 
-        final Interpolator interpolator;
-        final long duration;
+        final ValueAnimator animator;
         if (mSwipeDetector.isIdleState()) {
-            interpolator = getIdleInterpolator();
-            duration = defaultDuration;
+            setUpCloseAnimation(defaultDuration);
+            animator = mOpenCloseAnimation.getAnimationPlayer();
+            animator.setInterpolator(getIdleInterpolator());
         } else {
-            interpolator = mScrollInterpolator;
-            duration = mScrollDuration > NO_DURATION ? mScrollDuration : defaultDuration;
+            animator = mOpenCloseAnimation.getAnimationPlayer();
+            animator.setInterpolator(mScrollInterpolator);
+            animator.setDuration(mScrollDuration);
+            mOpenCloseAnimation.getAnimationPlayer().setFloatValues(
+                    mOpenCloseAnimation.getProgressFraction(), mScrollEndProgress);
         }
-        setUpOpenCloseAnimator(TRANSLATION_SHIFT_CLOSED, interpolator)
-                .addListener(AnimatorListeners.forEndCallback(this::onCloseComplete));
-        mOpenCloseAnimator.setDuration(duration).start();
+
+        animator.addListener(AnimatorListeners.forEndCallback(this::onCloseComplete));
+        animator.start();
     }
 
     protected Interpolator getIdleInterpolator() {
diff --git a/src/com/android/launcher3/views/WidgetsEduView.java b/src/com/android/launcher3/views/WidgetsEduView.java
index 92e048b..e70b1cb 100644
--- a/src/com/android/launcher3/views/WidgetsEduView.java
+++ b/src/com/android/launcher3/views/WidgetsEduView.java
@@ -116,11 +116,11 @@
     }
 
     private void animateOpen() {
-        if (mIsOpen || mOpenCloseAnimator.isRunning()) {
+        if (mIsOpen || mOpenCloseAnimation.getAnimationPlayer().isRunning()) {
             return;
         }
         mIsOpen = true;
-        setUpDefaultOpenAnimator().start();
+        setUpDefaultOpenAnimation().start();
     }
 
     /** Shows widget education dialog. */
diff --git a/src/com/android/launcher3/widget/AddItemWidgetsBottomSheet.java b/src/com/android/launcher3/widget/AddItemWidgetsBottomSheet.java
index c6fc5fe..80b1cdd 100644
--- a/src/com/android/launcher3/widget/AddItemWidgetsBottomSheet.java
+++ b/src/com/android/launcher3/widget/AddItemWidgetsBottomSheet.java
@@ -128,11 +128,11 @@
     }
 
     private void animateOpen() {
-        if (mIsOpen || mOpenCloseAnimator.isRunning()) {
+        if (mIsOpen || mOpenCloseAnimation.getAnimationPlayer().isRunning()) {
             return;
         }
         mIsOpen = true;
-        setUpDefaultOpenAnimator().start();
+        setUpDefaultOpenAnimation().start();
     }
 
     @Override
diff --git a/src/com/android/launcher3/widget/WidgetsBottomSheet.java b/src/com/android/launcher3/widget/WidgetsBottomSheet.java
index 82394f1..c347939 100644
--- a/src/com/android/launcher3/widget/WidgetsBottomSheet.java
+++ b/src/com/android/launcher3/widget/WidgetsBottomSheet.java
@@ -224,12 +224,12 @@
     }
 
     private void animateOpen() {
-        if (mIsOpen || mOpenCloseAnimator.isRunning()) {
+        if (mIsOpen || mOpenCloseAnimation.getAnimationPlayer().isRunning()) {
             return;
         }
         mIsOpen = true;
         setupNavBarColor();
-        setUpDefaultOpenAnimator().start();
+        setUpDefaultOpenAnimation().start();
     }
 
     @Override
diff --git a/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java b/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java
index 43f1846..abca1f8 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java
@@ -22,6 +22,7 @@
 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_WIDGETSTRAY_SEARCHED;
 import static com.android.launcher3.testing.shared.TestProtocol.NORMAL_STATE_ORDINAL;
 
+import android.animation.Animator;
 import android.content.Context;
 import android.content.pm.LauncherApps;
 import android.content.res.Configuration;
@@ -624,13 +625,12 @@
                 mContent.setAlpha(0);
                 setTranslationShift(VERTICAL_START_POSITION);
             }
-            setUpOpenCloseAnimator(
-                    TRANSLATION_SHIFT_OPENED,
-                    AnimationUtils.loadInterpolator(
-                            getContext(), android.R.interpolator.linear_out_slow_in));
+            setUpOpenAnimation(mActivityContext.getDeviceProfile().bottomSheetOpenDuration);
+            Animator animator = mOpenCloseAnimation.getAnimationPlayer();
+            animator.setInterpolator(AnimationUtils.loadInterpolator(
+                    getContext(), android.R.interpolator.linear_out_slow_in));
             post(() -> {
-                mOpenCloseAnimator
-                        .setDuration(mActivityContext.getDeviceProfile().bottomSheetOpenDuration)
+                animator.setDuration(mActivityContext.getDeviceProfile().bottomSheetOpenDuration)
                         .start();
                 mContent.animate().alpha(1).setDuration(FADE_IN_DURATION);
             });
diff --git a/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt
index 89159a7..dd79ca8 100644
--- a/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt
+++ b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt
@@ -159,41 +159,55 @@
     }
 
     protected fun initializeVarsForTwoPanel(
-        deviceTabletSpec: DeviceSpec,
-        deviceSpec: DeviceSpec,
+        deviceSpecUnfolded: DeviceSpec,
+        deviceSpecFolded: DeviceSpec,
         isLandscape: Boolean = false,
-        isGestureMode: Boolean = true
+        isGestureMode: Boolean = true,
+        isFolded: Boolean = false
     ) {
-        val (tabletNaturalX, tabletNaturalY) = deviceTabletSpec.naturalSize
-        val tabletWindowsBounds =
-            tabletWindowsBounds(deviceTabletSpec, tabletNaturalX, tabletNaturalY)
-        val tabletDisplayInfo =
+        val (unfoldedNaturalX, unfoldedNaturalY) = deviceSpecUnfolded.naturalSize
+        val unfoldedWindowsBounds =
+            tabletWindowsBounds(deviceSpecUnfolded, unfoldedNaturalX, unfoldedNaturalY)
+        val unfoldedDisplayInfo =
             CachedDisplayInfo(
-                Point(tabletNaturalX, tabletNaturalY),
+                Point(unfoldedNaturalX, unfoldedNaturalY),
                 Surface.ROTATION_0,
                 Rect(0, 0, 0, 0)
             )
 
-        val (phoneNaturalX, phoneNaturalY) = deviceSpec.naturalSize
-        val phoneWindowsBounds =
-            phoneWindowsBounds(deviceSpec, isGestureMode, phoneNaturalX, phoneNaturalY)
-        val phoneDisplayInfo =
+        val (foldedNaturalX, foldedNaturalY) = deviceSpecFolded.naturalSize
+        val foldedWindowsBounds =
+            phoneWindowsBounds(deviceSpecFolded, isGestureMode, foldedNaturalX, foldedNaturalY)
+        val foldedDisplayInfo =
             CachedDisplayInfo(
-                Point(phoneNaturalX, phoneNaturalY),
+                Point(foldedNaturalX, foldedNaturalY),
                 Surface.ROTATION_0,
                 Rect(0, 0, 0, 0)
             )
 
         val perDisplayBoundsCache =
-            mapOf(tabletDisplayInfo to tabletWindowsBounds, phoneDisplayInfo to phoneWindowsBounds)
+            mapOf(
+                unfoldedDisplayInfo to unfoldedWindowsBounds,
+                foldedDisplayInfo to foldedWindowsBounds
+            )
 
-        initializeCommonVars(
-            perDisplayBoundsCache,
-            tabletDisplayInfo,
-            rotation = if (isLandscape) Surface.ROTATION_0 else Surface.ROTATION_90,
-            isGestureMode,
-            densityDpi = deviceTabletSpec.densityDpi
-        )
+        if (isFolded) {
+            initializeCommonVars(
+                perDisplayBoundsCache = perDisplayBoundsCache,
+                displayInfo = foldedDisplayInfo,
+                rotation = if (isLandscape) Surface.ROTATION_90 else Surface.ROTATION_0,
+                isGestureMode = isGestureMode,
+                densityDpi = deviceSpecFolded.densityDpi
+            )
+        } else {
+            initializeCommonVars(
+                perDisplayBoundsCache = perDisplayBoundsCache,
+                displayInfo = unfoldedDisplayInfo,
+                rotation = if (isLandscape) Surface.ROTATION_0 else Surface.ROTATION_90,
+                isGestureMode = isGestureMode,
+                densityDpi = deviceSpecUnfolded.densityDpi
+            )
+        }
     }
 
     private fun phoneWindowsBounds(
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
index 87b87d3..38de2fa 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
@@ -16,11 +16,8 @@
 package com.android.launcher3.util.viewcapture_analysis;
 
 import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnalysisNode;
-import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnomalyDetector;
 
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 
 /**
  * Anomaly detector that triggers an error when alpha of a view changes too rapidly.
@@ -34,8 +31,7 @@
     private static final String RECENTS_DRAG_LAYER =
             CONTENT + "LauncherRootView:id/launcher|RecentsDragLayer:id/drag_layer|";
 
-    // Paths of nodes that are excluded from analysis.
-    private static final Iterable<String> PATHS_TO_IGNORE = List.of(
+    private static final IgnoreNode IGNORED_NODES_ROOT = buildIgnoreNodesTree(List.of(
             CONTENT
                     + "AddItemDragLayer:id/add_item_drag_layer|AddItemWidgetsBottomSheet:id"
                     + "/add_item_bottom_sheet|LinearLayout:id/add_item_bottom_sheet_content"
@@ -135,38 +131,7 @@
                     + "NexusOverviewActionsView:id/overview_actions_view"
                     + "|LinearLayout:id/action_buttons|Button:id/action_split",
             DRAG_LAYER + "IconView"
-    );
-
-    /**
-     * Element of the tree of ignored nodes.
-     * If the "children" map is empty, then this node should be ignored, i.e. alpha jumps analysis
-     * shouldn't run for it.
-     * I.e. ignored nodes correspond to the leaves in the ignored nodes tree.
-     */
-    private static class IgnoreNode {
-        // Map from child node identities to ignore-nodes for these children.
-        public final Map<String, IgnoreNode> children = new HashMap<>();
-    }
-
-    private static final IgnoreNode IGNORED_NODES_ROOT = buildIgnoreNodesTree();
-
-    // Converts the list of full paths of nodes to ignore to a more efficient tree of ignore-nodes.
-    private static IgnoreNode buildIgnoreNodesTree() {
-        final IgnoreNode root = new IgnoreNode();
-        for (String pathToIgnore : PATHS_TO_IGNORE) {
-            // Scan the diag path of an ignored node and add its elements into the tree.
-            IgnoreNode currentIgnoreNode = root;
-            for (String part : pathToIgnore.split("\\|")) {
-                // Ensure that the child of the node is added to the tree.
-                IgnoreNode child = currentIgnoreNode.children.get(part);
-                if (child == null) {
-                    currentIgnoreNode.children.put(part, child = new IgnoreNode());
-                }
-                currentIgnoreNode = child;
-            }
-        }
-        return root;
-    }
+    ));
 
     // Minimal increase or decrease of view's alpha between frames that triggers the error.
     private static final float ALPHA_JUMP_THRESHOLD = 1f;
@@ -213,7 +178,7 @@
     }
 
     @Override
-    String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN) {
+    String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN, long timestamp) {
         // If the view was previously seen, proceed with analysis only if it was present in the
         // view hierarchy in the previous frame.
         if (oldInfo != null && oldInfo.frameN != frameN) return null;
@@ -229,9 +194,8 @@
         if (alphaDeltaAbs >= ALPHA_JUMP_THRESHOLD) {
             nodeData.ignoreAlphaJumps = true; // No need to report alpha jump in children.
             return String.format(
-                    "Alpha jump detected in ViewCapture data: alpha change: %s (%s -> %s)"
-                            + ", threshold: %s, %s", // ----------- no need to include view?
-                    alphaDeltaAbs, oldAlpha, newAlpha, ALPHA_JUMP_THRESHOLD, latestInfo);
+                    "Alpha jump detected: alpha change: %s (%s -> %s), threshold: %s",
+                    alphaDeltaAbs, oldAlpha, newAlpha, ALPHA_JUMP_THRESHOLD);
         }
         return null;
     }
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java
new file mode 100644
index 0000000..09e2f65
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2023 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.util.viewcapture_analysis;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Detector of one kind of anomaly.
+ */
+abstract class AnomalyDetector {
+    // Index of this detector in ViewCaptureAnalyzer.ANOMALY_DETECTORS
+    public int detectorOrdinal;
+
+    /**
+     * Element of the tree of ignored nodes.
+     * If the "children" map is empty, then this node should be ignored, i.e. the analysis shouldn't
+     * run for it.
+     * I.e. ignored nodes correspond to the leaves in the ignored nodes tree.
+     */
+    protected static class IgnoreNode {
+        // Map from child node identities to ignore-nodes for these children.
+        public final Map<String, IgnoreNode> children = new HashMap<>();
+    }
+
+    // Converts the list of full paths of nodes to ignore to a more efficient tree of ignore-nodes.
+    protected static IgnoreNode buildIgnoreNodesTree(Iterable<String> pathsToIgnore) {
+        final IgnoreNode root = new IgnoreNode();
+        for (String pathToIgnore : pathsToIgnore) {
+            // Scan the diag path of an ignored node and add its elements into the tree.
+            IgnoreNode currentIgnoreNode = root;
+            for (String part : pathToIgnore.split("\\|")) {
+                // Ensure that the child of the node is added to the tree.
+                IgnoreNode child = currentIgnoreNode.children.get(part);
+                if (child == null) {
+                    currentIgnoreNode.children.put(part, child = new IgnoreNode());
+                }
+                currentIgnoreNode = child;
+            }
+        }
+        return root;
+    }
+
+    /**
+     * Initializes fields of the node that are specific to the anomaly detected by this
+     * detector.
+     */
+    abstract void initializeNode(@NonNull ViewCaptureAnalyzer.AnalysisNode info);
+
+    /**
+     * Detects anomalies by looking at the last occurrence of a view, and the current one.
+     * null value means that the view. 'oldInfo' and 'newInfo' cannot be both null.
+     * If an anomaly is detected, an exception will be thrown.
+     *
+     * @param oldInfo the view, as seen in the last frame that contained it in the view
+     *                hierarchy before 'currentFrame'. 'null' means that the view is first seen
+     *                in the 'currentFrame'.
+     * @param newInfo the view in the view hierarchy of the 'currentFrame'. 'null' means that
+     *                the view is not present in the 'currentFrame', but was present in the previous
+     *                frame.
+     * @param frameN  number of the current frame.
+     * @return Anomaly diagnostic message if an anomaly has been detected; null otherwise.
+     */
+    abstract String detectAnomalies(
+            @Nullable ViewCaptureAnalyzer.AnalysisNode oldInfo,
+            @Nullable ViewCaptureAnalyzer.AnalysisNode newInfo, int frameN,
+            long frameTimeNs);
+}
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java
new file mode 100644
index 0000000..d9517b0
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2023 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.util.viewcapture_analysis;
+
+import static org.junit.Assert.assertTrue;
+
+import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnalysisNode;
+
+import java.util.List;
+
+/**
+ * Anomaly detector that triggers an error when a view flashes, i.e. appears or disappears for a too
+ * short period of time.
+ */
+final class FlashDetector extends AnomalyDetector {
+    // Maximum time period of a view visibility or invisibility that is recognized as a flash.
+    private static final int FLASH_DURATION_MS = 300;
+
+    // Commonly used parts of the paths to ignore.
+    private static final String CONTENT = "DecorView|LinearLayout|FrameLayout:id/content|";
+    private static final String DRAG_LAYER =
+            CONTENT + "LauncherRootView:id/launcher|DragLayer:id/drag_layer|";
+    private static final String RECENTS_DRAG_LAYER =
+            CONTENT + "LauncherRootView:id/launcher|RecentsDragLayer:id/drag_layer|";
+
+    private static final IgnoreNode IGNORED_NODES_ROOT = buildIgnoreNodesTree(List.of(
+            CONTENT + "LauncherRootView:id/launcher|FloatingIconView",
+            DRAG_LAYER
+                    + "SearchContainerView:id/apps_view|AllAppsRecyclerView:id/apps_list_view"
+                    + "|BubbleTextView:id/icon",
+            DRAG_LAYER + "LauncherDragView|ImageView",
+            DRAG_LAYER + "LauncherRecentsView:id/overview_panel|TaskView|TextView",
+            DRAG_LAYER
+                    + "LauncherAllAppsContainerView:id/apps_view|AllAppsRecyclerView:id"
+                    + "/apps_list_view|BubbleTextView:id/icon",
+            DRAG_LAYER + "LauncherDragView|View",
+            CONTENT
+                    + "AddItemDragLayer:id/add_item_drag_layer|AddItemWidgetsBottomSheet:id"
+                    + "/add_item_bottom_sheet|LinearLayout:id/add_item_bottom_sheet_content"
+                    + "|ScrollView:id/widget_preview_scroll_view|WidgetCell:id/widget_cell"
+                    + "|WidgetCellPreview:id/widget_preview_container|WidgetImageView:id"
+                    + "/widget_preview",
+            CONTENT
+                    + "AddItemDragLayer:id/add_item_drag_layer|AddItemWidgetsBottomSheet:id"
+                    + "/add_item_bottom_sheet|LinearLayout:id/add_item_bottom_sheet_content"
+                    + "|ScrollView:id/widget_preview_scroll_view|WidgetCell:id/widget_cell"
+                    + "|WidgetCellPreview:id/widget_preview_container|ImageView:id/widget_badge",
+            RECENTS_DRAG_LAYER + "FallbackRecentsView:id/overview_panel|TaskView|IconView:id/icon",
+            DRAG_LAYER
+                    + "SearchContainerView:id/apps_view|UniversalSearchInputView:id"
+                    + "/search_container_all_apps|View:id/ripple"
+    ));
+
+    // Per-AnalysisNode data that's specific to this detector.
+    private static class NodeData {
+        public boolean ignoreFlashes;
+
+        // If ignoreNode is null, then this AnalysisNode node will be ignored if its parent is
+        // ignored.
+        // Otherwise, this AnalysisNode will be ignored if ignoreNode is a leaf i.e. has no
+        // children.
+        public IgnoreNode ignoreNode;
+    }
+
+    private NodeData getNodeData(AnalysisNode info) {
+        return (NodeData) info.detectorsData[detectorOrdinal];
+    }
+
+    @Override
+    void initializeNode(AnalysisNode info) {
+        final NodeData nodeData = new NodeData();
+        info.detectorsData[detectorOrdinal] = nodeData;
+
+        // If the parent view ignores flashes, its descendants will too.
+        final boolean parentIgnoresFlashes = info.parent != null && getNodeData(
+                info.parent).ignoreFlashes;
+        if (parentIgnoresFlashes) {
+            nodeData.ignoreFlashes = true;
+            return;
+        }
+
+        // Parent view doesn't ignore flashes.
+        // Initialize this AnalysisNode's ignore-node with the corresponding child of the
+        // ignore-node of the parent, if present.
+        final IgnoreNode parentIgnoreNode = info.parent != null
+                ? getNodeData(info.parent).ignoreNode
+                : IGNORED_NODES_ROOT;
+        nodeData.ignoreNode = parentIgnoreNode != null
+                ? parentIgnoreNode.children.get(info.nodeIdentity) : null;
+        // AnalysisNode will be ignored if the corresponding ignore-node is a leaf.
+        nodeData.ignoreFlashes =
+                nodeData.ignoreNode != null && nodeData.ignoreNode.children.isEmpty();
+    }
+
+    @Override
+    String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN,
+            long frameTimeNs) {
+        // Should we check when a view was visible for a short period, then its alpha became 0?
+        // Then 'lastVisible' time should be the last one still visible?
+        // Check only transitions of alpha between 0 and 1?
+
+        // If this is the first time ever when we see the view, there have been no flashes yet.
+        if (oldInfo == null) return null;
+
+        // A flash requires a view to go from the full visibility to no-visibility and then back,
+        // or vice versa.
+        // If the last time the view was seen before the current frame, it didn't have full
+        // visibility; no flash can possibly be detected at the current frame.
+        if (oldInfo.alpha < 1) return null;
+
+        final AnalysisNode latestInfo = newInfo != null ? newInfo : oldInfo;
+        final NodeData nodeData = getNodeData(latestInfo);
+        if (nodeData.ignoreFlashes) return null;
+
+        // Once the view becomes invisible, see for how long it was visible prior to that. If it
+        // was visible only for a short interval of time, it's a flash.
+        if (
+            // View is invisible in the current frame
+                newInfo == null
+                        // When the view became visible last time, it was a transition from
+                        // no-visibility to full visibility.
+                        && oldInfo.timeBecameVisibleNs != -1) {
+            final long wasVisibleTimeMs = (frameTimeNs - oldInfo.timeBecameVisibleNs) / 1000000;
+
+            if (wasVisibleTimeMs <= FLASH_DURATION_MS) {
+                nodeData.ignoreFlashes = true; // No need to report flashes in children.
+                return
+                        String.format(
+                                "View was visible for a too short period of time %dms, which is a"
+                                        + " flash",
+                                wasVisibleTimeMs
+                        );
+            }
+        }
+
+        // Once a view becomes visible, see for how long it was invisible prior to that. If it
+        // was invisible only for a short interval of time, it's a flash.
+        if (
+            // The view is fully visible now
+                newInfo != null && newInfo.alpha >= 1
+                        // The view wasn't visible in the previous frame
+                        && frameN != oldInfo.frameN + 1) {
+            // We can assert the below condition because at this point, we know that
+            // oldInfo.alpha >= 1, i.e. it disappeared abruptly.
+            assertTrue("oldInfo.timeBecameInvisibleNs must not be -1",
+                    oldInfo.timeBecameInvisibleNs != -1);
+
+            final long wasInvisibleTimeMs = (frameTimeNs - oldInfo.timeBecameInvisibleNs) / 1000000;
+            if (wasInvisibleTimeMs <= FLASH_DURATION_MS) {
+                nodeData.ignoreFlashes = true; // No need to report flashes in children.
+                return
+                        String.format(
+                                "View was invisible for a too short period of time %dms, which "
+                                        + "is a flash",
+                                wasInvisibleTimeMs);
+            }
+        }
+        return null;
+    }
+}
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
index 949c536..ccb4a1e 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
@@ -17,9 +17,6 @@
 
 import static android.view.View.VISIBLE;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
 import com.android.app.viewcapture.data.ExportedData;
 import com.android.app.viewcapture.data.FrameData;
 import com.android.app.viewcapture.data.ViewNode;
@@ -36,40 +33,10 @@
 public class ViewCaptureAnalyzer {
     private static final String SCRIM_VIEW_CLASS = "com.android.launcher3.views.ScrimView";
 
-    /**
-     * Detector of one kind of anomaly.
-     */
-    abstract static class AnomalyDetector {
-        // Index of this detector in ViewCaptureAnalyzer.ANOMALY_DETECTORS
-        public int detectorOrdinal;
-
-        /**
-         * Initializes fields of the node that are specific to the anomaly detected by this
-         * detector.
-         */
-        abstract void initializeNode(@NonNull AnalysisNode info);
-
-        /**
-         * Detects anomalies by looking at the last occurrence of a view, and the current one.
-         * null value means that the view. 'oldInfo' and 'newInfo' cannot be both null.
-         * If an anomaly is detected, an exception will be thrown.
-         *
-         * @param oldInfo the view, as seen in the last frame that contained it in the view
-         *                hierarchy before 'currentFrame'. 'null' means that the view is first seen
-         *                in the 'currentFrame'.
-         * @param newInfo the view in the view hierarchy of the 'currentFrame'. 'null' means that
-         *                the view is not present in the 'currentFrame', but was present in earlier
-         *                frames.
-         * @param frameN  number of the current frame.
-         * @return Anomaly diagnostic message if an anomaly has been detected; null otherwise.
-         */
-        abstract String detectAnomalies(
-                @Nullable AnalysisNode oldInfo, @Nullable AnalysisNode newInfo, int frameN);
-    }
-
     // All detectors. They will be invoked in the order listed here.
     private static final AnomalyDetector[] ANOMALY_DETECTORS = {
-            new AlphaJumpDetector()
+            new AlphaJumpDetector(),
+            new FlashDetector()
     };
 
     static {
@@ -89,9 +56,21 @@
         // Visible scale and alpha, build recursively from the ancestor list.
         public float scaleX;
         public float scaleY;
-        public float alpha;
+        public float alpha; // Always > 0
 
         public int frameN;
+
+        // Timestamp of the frame when this view became abruptly visible, i.e. its alpha became 1
+        // the next frame after it was 0 or the view wasn't visible.
+        // If the view is currently invisible or the last appearance wasn't abrupt, the value is -1.
+        public long timeBecameVisibleNs;
+
+        // Timestamp of the frame when this view became abruptly invisible last time, i.e. its
+        // alpha became 0, or view disappeared, after being 1 in the previous frame.
+        // If the view is currently visible or the last disappearance wasn't abrupt, the value is
+        // -1.
+        public long timeBecameInvisibleNs;
+
         public ViewNode viewCaptureNode;
 
         // Class name + resource id
@@ -143,7 +122,9 @@
             Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
             Map<String, String> anomalies) {
         // Analyze the node tree starting from the root.
+        long frameTimeNs = frame.getTimestamp();
         analyzeView(
+                frameTimeNs,
                 frame.getNode(),
                 /* parent = */ null,
                 frameN,
@@ -154,7 +135,7 @@
                 scrimClassIndex,
                 anomalies);
 
-        // Analyze transitions when a view visible in the last frame become invisible in the
+        // Analyze transitions when a view visible in the previous frame became invisible in the
         // current one.
         for (AnalysisNode info : lastSeenNodes.values()) {
             if (info.frameN == frameN - 1) {
@@ -166,14 +147,18 @@
                                             frameN,
                                             /* oldInfo = */ info,
                                             /* newInfo = */ null,
-                                            anomalies)
+                                            anomalies,
+                                            frameTimeNs)
                     );
                 }
+                info.timeBecameInvisibleNs = info.alpha == 1 ? frameTimeNs : -1;
+                info.timeBecameVisibleNs = -1;
             }
         }
     }
 
-    private static void analyzeView(ViewNode viewCaptureNode, AnalysisNode parent, int frameN,
+    private static void analyzeView(long frameTimeNs, ViewNode viewCaptureNode, AnalysisNode parent,
+            int frameN,
             float leftShift, float topShift, ExportedData viewCaptureData,
             Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
             Map<String, String> anomalies) {
@@ -211,17 +196,31 @@
         newAnalysisNode.scaleY = scaleY;
         newAnalysisNode.alpha = alpha;
         newAnalysisNode.frameN = frameN;
+        newAnalysisNode.timeBecameInvisibleNs = -1;
         newAnalysisNode.viewCaptureNode = viewCaptureNode;
         Arrays.stream(ANOMALY_DETECTORS).forEach(
                 detector -> detector.initializeNode(newAnalysisNode));
 
-        // Detect anomalies for the view
         final AnalysisNode oldAnalysisNode = lastSeenNodes.get(hashcode); // may be null
+
+        if (oldAnalysisNode != null && oldAnalysisNode.frameN + 1 == frameN) {
+            // If this view was present in the previous frame, keep the time when it became visible.
+            newAnalysisNode.timeBecameVisibleNs = oldAnalysisNode.timeBecameVisibleNs;
+        } else {
+            // If the view is becoming visible after being invisible, initialize the time when it
+            // became visible with a new value.
+            // If the view became visible abruptly, i.e. alpha jumped from 0 to 1 between the
+            // previous and the current frames, then initialize with the time of the current
+            // frame. Otherwise, use -1.
+            newAnalysisNode.timeBecameVisibleNs = newAnalysisNode.alpha >= 1 ? frameTimeNs : -1;
+        }
+
+        // Detect anomalies for the view.
         if (frameN != 0 && !viewCaptureNode.getWillNotDraw()) {
             Arrays.stream(ANOMALY_DETECTORS).forEach(
                     detector ->
                             detectAnomaly(detector, frameN, oldAnalysisNode, newAnalysisNode,
-                                    anomalies)
+                                    anomalies, frameTimeNs)
             );
         }
         lastSeenNodes.put(hashcode, newAnalysisNode);
@@ -236,20 +235,22 @@
             // transparent.
             if (child.getClassnameIndex() == scrimClassIndex) break;
 
-            analyzeView(child, newAnalysisNode, frameN, leftShiftForChildren, topShiftForChildren,
+            analyzeView(frameTimeNs, child, newAnalysisNode, frameN, leftShiftForChildren,
+                    topShiftForChildren,
                     viewCaptureData, lastSeenNodes, scrimClassIndex, anomalies);
         }
     }
 
     private static void detectAnomaly(AnomalyDetector detector, int frameN,
             AnalysisNode oldAnalysisNode, AnalysisNode newAnalysisNode,
-            Map<String, String> anomalies) {
+            Map<String, String> anomalies, long frameTimeNs) {
         final String maybeAnomaly =
-                detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN);
+                detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN, frameTimeNs);
         if (maybeAnomaly != null) {
-            final String viewDiagPath = diagPathFromRoot(newAnalysisNode);
+            AnalysisNode latestInfo = newAnalysisNode != null ? newAnalysisNode : oldAnalysisNode;
+            final String viewDiagPath = diagPathFromRoot(latestInfo);
             if (!anomalies.containsKey(viewDiagPath)) {
-                anomalies.put(viewDiagPath, maybeAnomaly);
+                anomalies.put(viewDiagPath, String.format("%s, %s", maybeAnomaly, latestInfo));
             }
         }
     }