[Unfold animation] Start Launcher animation preemptively to synchronize the first frame

Starts unfold animation in Launcher right after receiving
configuration change for the unfolded screen.
This makes sure that before we unblock the screen we
have the first frame of the unfold animation ready
in Launcher (transformations are applied).

Bug: 271099882
Test: atest com.android.systemui.unfold.util.PreemptiveUnfoldTransitionProgressProviderTest
Test: manual testing fold/unfold, checking perfetto traces
Test: test with flag enabled/disabled
Change-Id: Icb8f91f9264248600d4bed14811445f50aac99c7
diff --git a/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java b/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java
index ad11b7e..328a727 100644
--- a/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java
+++ b/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java
@@ -43,6 +43,10 @@
             new UnfoldMoveFromCenterRotationListener();
     private boolean mAnimationInProgress = false;
 
+    // Save the last transition progress so we can re-apply it in case we re-register the view for
+    // the animation (by calling onPrepareViewsForAnimation)
+    private Float mLastTransitionProgress = null;
+
     public BaseUnfoldMoveFromCenterAnimator(WindowManager windowManager,
             RotationChangeProvider rotationChangeProvider) {
         mMoveFromCenterAnimation = new UnfoldMoveFromCenterAnimator(windowManager,
@@ -63,11 +67,13 @@
     @Override
     public void onTransitionProgress(float progress) {
         mMoveFromCenterAnimation.onTransitionProgress(progress);
+        mLastTransitionProgress = progress;
     }
 
     @CallSuper
     @Override
     public void onTransitionFinished() {
+        mLastTransitionProgress = null;
         mAnimationInProgress = false;
         mRotationChangeProvider.removeCallback(mRotationListener);
         mMoveFromCenterAnimation.onTransitionFinished();
@@ -93,8 +99,11 @@
         mOriginalClipToPadding.clear();
     }
 
+    @CallSuper
     protected void onPrepareViewsForAnimation() {
-
+        if (mLastTransitionProgress != null) {
+            mMoveFromCenterAnimation.onTransitionProgress(mLastTransitionProgress);
+        }
     }
 
     protected void registerViewForAnimation(View view) {
diff --git a/quickstep/src/com/android/quickstep/util/LauncherUnfoldAnimationController.java b/quickstep/src/com/android/quickstep/util/LauncherUnfoldAnimationController.java
index 8fdafc6..6d15e8b 100644
--- a/quickstep/src/com/android/quickstep/util/LauncherUnfoldAnimationController.java
+++ b/quickstep/src/com/android/quickstep/util/LauncherUnfoldAnimationController.java
@@ -27,10 +27,15 @@
 
 import androidx.core.view.OneShotPreDrawListener;
 
+import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.DeviceProfile.OnDeviceProfileChangeListener;
 import com.android.launcher3.Hotseat;
 import com.android.launcher3.Launcher;
 import com.android.launcher3.Workspace;
+import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.util.HorizontalInsettableView;
+import com.android.quickstep.SystemUiProxy;
+import com.android.quickstep.util.unfold.PreemptiveUnfoldTransitionProgressProvider;
 import com.android.systemui.unfold.UnfoldTransitionProgressProvider;
 import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener;
 import com.android.systemui.unfold.updates.RotationChangeProvider;
@@ -40,7 +45,7 @@
 /**
  * Controls animations that are happening during unfolding foldable devices
  */
-public class LauncherUnfoldAnimationController {
+public class LauncherUnfoldAnimationController implements OnDeviceProfileChangeListener {
 
     // Percentage of the width of the quick search bar that will be reduced
     // from the both sides of the bar when progress is 0
@@ -55,9 +60,11 @@
     private final NaturalRotationUnfoldProgressProvider mNaturalOrientationProgressProvider;
     private final UnfoldMoveFromCenterHotseatAnimator mUnfoldMoveFromCenterHotseatAnimator;
     private final UnfoldMoveFromCenterWorkspaceAnimator mUnfoldMoveFromCenterWorkspaceAnimator;
+    private PreemptiveUnfoldTransitionProgressProvider mPreemptiveProgressProvider = null;
+    private Boolean mIsTablet = null;
 
     private static final String TRACE_WAIT_TO_HANDLE_UNFOLD_TRANSITION =
-            "waitingOneFrameBeforeHandlingUnfoldAnimation";
+            "LauncherUnfoldAnimationController#waitingForTheNextFrame";
 
     @Nullable
     private HorizontalInsettableView mQsbInsettable;
@@ -68,8 +75,19 @@
             UnfoldTransitionProgressProvider unfoldTransitionProgressProvider,
             RotationChangeProvider rotationChangeProvider) {
         mLauncher = launcher;
-        mProgressProvider = new ScopedUnfoldTransitionProgressProvider(
-                unfoldTransitionProgressProvider);
+
+        if (FeatureFlags.PREEMPTIVE_UNFOLD_ANIMATION_START.get()) {
+            mPreemptiveProgressProvider = new PreemptiveUnfoldTransitionProgressProvider(
+                    unfoldTransitionProgressProvider, launcher.getMainThreadHandler());
+            mPreemptiveProgressProvider.init();
+
+            mProgressProvider = new ScopedUnfoldTransitionProgressProvider(
+                    mPreemptiveProgressProvider);
+        } else {
+            mProgressProvider = new ScopedUnfoldTransitionProgressProvider(
+                    unfoldTransitionProgressProvider);
+        }
+
         mUnfoldMoveFromCenterHotseatAnimator = new UnfoldMoveFromCenterHotseatAnimator(launcher,
                 windowManager, rotationChangeProvider);
         mUnfoldMoveFromCenterWorkspaceAnimator = new UnfoldMoveFromCenterWorkspaceAnimator(launcher,
@@ -85,6 +103,8 @@
         // Animated only in natural orientation
         mNaturalOrientationProgressProvider.addCallback(new QsbAnimationListener());
         mNaturalOrientationProgressProvider.addCallback(mUnfoldMoveFromCenterHotseatAnimator);
+
+        mLauncher.addOnDeviceProfileChangeListener(this);
     }
 
     /**
@@ -96,17 +116,21 @@
             mQsbInsettable = (HorizontalInsettableView) hotseat.getQsb();
         }
 
-        handleTransitionOnNextFrame();
+        mProgressProvider.setReadyToHandleTransition(true);
     }
 
-    private void handleTransitionOnNextFrame() {
+    private void preemptivelyStartAnimationOnNextFrame() {
         Trace.asyncTraceBegin(Trace.TRACE_TAG_APP,
                 TRACE_WAIT_TO_HANDLE_UNFOLD_TRANSITION, /* cookie= */ 0);
+
+        // Start the animation (and apply the transformations) in pre-draw listener to make sure
+        // that the views are laid out as some transformations depend on the view sizes and position
         OneShotPreDrawListener.add(mLauncher.getWorkspace(),
                 () -> {
                     Trace.asyncTraceEnd(Trace.TRACE_TAG_APP,
                             TRACE_WAIT_TO_HANDLE_UNFOLD_TRANSITION, /* cookie= */ 0);
-                    mProgressProvider.setReadyToHandleTransition(true);
+                    mPreemptiveProgressProvider.preemptivelyStartTransition(
+                            /* initialProgress= */ 0f);
                 });
     }
 
@@ -124,14 +148,34 @@
     public void onDestroy() {
         mProgressProvider.destroy();
         mNaturalOrientationProgressProvider.destroy();
+        mLauncher.removeOnDeviceProfileChangeListener(this);
     }
 
-    /** Called when launcher finished binding its items. */
+    /**
+     * Called when launcher has finished binding its items
+     */
     public void updateRegisteredViewsIfNeeded() {
         mUnfoldMoveFromCenterHotseatAnimator.updateRegisteredViewsIfNeeded();
         mUnfoldMoveFromCenterWorkspaceAnimator.updateRegisteredViewsIfNeeded();
     }
 
+    @Override
+    public void onDeviceProfileChanged(DeviceProfile dp) {
+        if (!FeatureFlags.PREEMPTIVE_UNFOLD_ANIMATION_START.get()) {
+            return;
+        }
+
+        if (mIsTablet != null && dp.isTablet != mIsTablet) {
+            if (dp.isTablet && SystemUiProxy.INSTANCE.get(mLauncher).isActive()) {
+                // Preemptively start the unfold animation to make sure that we have drawn
+                // the first frame of the animation before the screen gets unblocked
+                preemptivelyStartAnimationOnNextFrame();
+            }
+        }
+
+        mIsTablet = dp.isTablet;
+    }
+
     private class QsbAnimationListener implements TransitionProgressListener {
 
         @Override
diff --git a/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterHotseatAnimator.java b/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterHotseatAnimator.java
index 70a12d6..c8141b4 100644
--- a/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterHotseatAnimator.java
+++ b/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterHotseatAnimator.java
@@ -48,6 +48,8 @@
             View child = hotseatIcons.getChildAt(i);
             registerViewForAnimation(child);
         }
+
+        super.onPrepareViewsForAnimation();
     }
 
     @Override
diff --git a/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterWorkspaceAnimator.java b/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterWorkspaceAnimator.java
index 7da103e..c05b38f 100644
--- a/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterWorkspaceAnimator.java
+++ b/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterWorkspaceAnimator.java
@@ -58,6 +58,8 @@
 
         setClipChildren(workspace, false);
         setClipToPadding(workspace, true);
+
+        super.onPrepareViewsForAnimation();
     }
 
     @Override
diff --git a/quickstep/src/com/android/quickstep/util/unfold/PreemptiveUnfoldTransitionProgressProvider.kt b/quickstep/src/com/android/quickstep/util/unfold/PreemptiveUnfoldTransitionProgressProvider.kt
new file mode 100644
index 0000000..a9cd048
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/unfold/PreemptiveUnfoldTransitionProgressProvider.kt
@@ -0,0 +1,161 @@
+/*
+ * 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.quickstep.util.unfold
+
+import android.os.Handler
+import android.os.Trace
+import android.util.Log
+import com.android.systemui.unfold.UnfoldTransitionProgressProvider
+import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener
+
+/**
+ * Transition progress provider wrapper that can preemptively start the transition on demand
+ * without relying on the source provider. When the source provider has started the animation
+ * it switches to it.
+ *
+ * This might be useful when we want to synchronously start the unfold animation and render
+ * the first frame during turning on the screen. For example, this is used in Launcher where
+ * we need to render the first frame of the animation immediately after receiving a configuration
+ * change event so Window Manager will wait for this frame to be rendered before unblocking
+ * the screen. We can't rely on the original transition progress as it starts the animation
+ * after the screen fully turned on (and unblocked), at this moment it is already too late to
+ * start the animation.
+ *
+ * Using this provider we could render the first frame preemptively by sending 'transition started'
+ * and '0' transition progress before the original progress provider sends these events.
+ */
+class PreemptiveUnfoldTransitionProgressProvider(
+        private val source: UnfoldTransitionProgressProvider,
+        private val handler: Handler
+) : UnfoldTransitionProgressProvider, TransitionProgressListener {
+
+    private val timeoutRunnable = Runnable {
+        if (isRunning) {
+            listeners.forEach { it.onTransitionFinished() }
+            onPreemptiveStartFinished()
+            Log.wtf(TAG, "Timeout occurred when waiting for the source transition to start")
+        }
+    }
+
+    private val listeners = arrayListOf<TransitionProgressListener>()
+    private var isPreemptivelyRunning = false
+    private var isSourceRunning = false
+
+    private val isRunning: Boolean
+        get() = isPreemptivelyRunning || isSourceRunning
+
+    private val sourceListener =
+            object : TransitionProgressListener {
+                override fun onTransitionStarted() {
+                    handler.removeCallbacks(timeoutRunnable)
+
+                    if (!isRunning) {
+                        listeners.forEach { it.onTransitionStarted() }
+                    }
+
+                    onPreemptiveStartFinished()
+                    isSourceRunning = true
+                }
+
+                override fun onTransitionProgress(progress: Float) {
+                    if (isRunning) {
+                        listeners.forEach { it.onTransitionProgress(progress) }
+                        isSourceRunning = true
+                    }
+                }
+
+                override fun onTransitionFinishing() {
+                    if (isRunning) {
+                        listeners.forEach { it.onTransitionFinishing() }
+                        isSourceRunning = true
+                    }
+                }
+
+                override fun onTransitionFinished() {
+                    if (isRunning) {
+                        listeners.forEach { it.onTransitionFinished() }
+                    }
+
+                    isSourceRunning = false
+                    onPreemptiveStartFinished()
+                    handler.removeCallbacks(timeoutRunnable)
+                }
+            }
+
+    fun init() {
+        source.addCallback(sourceListener)
+    }
+
+    /**
+     * Starts the animation preemptively.
+     *
+     * - If the source provider is already running, this method won't change any behavior
+     * - If the source provider has not started running yet, it will call onTransitionStarted
+     *   for all listeners and optionally onTransitionProgress(initialProgress) if supplied.
+     *   When the source provider starts the animation it will switch to send progress and finished
+     *   events from it.
+     *   If the source provider won't start the animation within a timeout, the animation will be
+     *   cancelled and onTransitionFinished will be delivered to the current listeners.
+     */
+    @JvmOverloads
+    fun preemptivelyStartTransition(initialProgress: Float? = null) {
+        if (!isRunning) {
+            Trace.beginAsyncSection("$TAG#startedPreemptively", 0)
+
+            listeners.forEach { it.onTransitionStarted() }
+            initialProgress?.let { progress ->
+                listeners.forEach { it.onTransitionProgress(progress) }
+            }
+
+            handler.removeCallbacks(timeoutRunnable)
+            handler.postDelayed(timeoutRunnable, PREEMPTIVE_UNFOLD_TIMEOUT_MS)
+        }
+
+        isPreemptivelyRunning = true
+    }
+
+    fun cancelPreemptiveStart() {
+        handler.removeCallbacks(timeoutRunnable)
+        if (isRunning) {
+            listeners.forEach { it.onTransitionFinished() }
+        }
+        onPreemptiveStartFinished()
+    }
+
+    private fun onPreemptiveStartFinished() {
+        if (isPreemptivelyRunning) {
+            Trace.endAsyncSection("$TAG#startedPreemptively", 0)
+            isPreemptivelyRunning = false
+        }
+    }
+
+    override fun destroy() {
+        handler.removeCallbacks(timeoutRunnable)
+        source.removeCallback(sourceListener)
+        source.destroy()
+    }
+
+    override fun addCallback(listener: TransitionProgressListener) {
+        listeners += listener
+    }
+
+    override fun removeCallback(listener: TransitionProgressListener) {
+        listeners -= listener
+    }
+}
+
+const val TAG = "PreemptiveUnfoldTransitionProgressProvider"
+const val PREEMPTIVE_UNFOLD_TIMEOUT_MS = 1700L
diff --git a/quickstep/tests/src/com/android/quickstep/util/unfold/PreemptiveUnfoldTransitionProgressProviderTest.kt b/quickstep/tests/src/com/android/quickstep/util/unfold/PreemptiveUnfoldTransitionProgressProviderTest.kt
new file mode 100644
index 0000000..f73be72
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/util/unfold/PreemptiveUnfoldTransitionProgressProviderTest.kt
@@ -0,0 +1,261 @@
+/*
+ * 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.quickstep.util.unfold
+
+import android.os.Handler
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.testing.TestableLooper.RunWithLooper
+import android.util.Log
+import androidx.test.filters.SmallTest
+import com.android.launcher3.util.any
+import com.android.launcher3.util.mock
+import com.android.systemui.unfold.UnfoldTransitionProgressProvider
+import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.anyBoolean
+import org.mockito.Mockito.anyFloat
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@RunWithLooper
+class PreemptiveUnfoldTransitionProgressProviderTest {
+
+    private lateinit var testableLooper: TestableLooper
+    private lateinit var source: TransitionProgressListener
+    private lateinit var handler: Handler
+    private lateinit var oldWtfHandler: Log.TerribleFailureHandler
+    private val listener: TransitionProgressListener = mock()
+    private val testWtfHandler: Log.TerribleFailureHandler = mock()
+
+    private lateinit var provider: PreemptiveUnfoldTransitionProgressProvider
+
+    @Before
+    fun before() {
+        testableLooper = TestableLooper.get(this)
+        handler = Handler(testableLooper.looper)
+
+        val testSource = createSource()
+        source = testSource as TransitionProgressListener
+
+        oldWtfHandler = Log.setWtfHandler(testWtfHandler)
+
+        provider = PreemptiveUnfoldTransitionProgressProvider(testSource, handler)
+        provider.init()
+        provider.addCallback(listener)
+    }
+
+    @After
+    fun after() {
+        Log.setWtfHandler(oldWtfHandler)
+    }
+
+    @Test
+    fun preemptiveStartInitialProgressNull_transitionStarts() {
+        provider.preemptivelyStartTransition(initialProgress = null)
+
+        verify(listener).onTransitionStarted()
+        verify(listener, never()).onTransitionProgress(anyFloat())
+    }
+
+    @Test
+    fun preemptiveStartWithInitialProgress_startsAnimationAndSendsProgress() {
+        provider.preemptivelyStartTransition(initialProgress = 0.5f)
+
+        verify(listener).onTransitionStarted()
+        verify(listener).onTransitionProgress(0.5f)
+    }
+
+    @Test
+    fun preemptiveStartAndCancel_finishesAnimation() {
+        provider.preemptivelyStartTransition()
+        provider.cancelPreemptiveStart()
+
+        with(inOrder(listener)) {
+            verify(listener).onTransitionStarted()
+            verify(listener).onTransitionFinished()
+        }
+    }
+
+    @Test
+    fun preemptiveStartAndThenSourceStartsTransition_transitionStarts() {
+        provider.preemptivelyStartTransition()
+        source.onTransitionStarted()
+
+        verify(listener).onTransitionStarted()
+    }
+
+    @Test
+    fun preemptiveStartAndThenSourceStartsAndFinishesTransition_transitionFinishes() {
+        provider.preemptivelyStartTransition()
+
+        source.onTransitionStarted()
+        source.onTransitionFinished()
+
+        with(inOrder(listener)) {
+            verify(listener).onTransitionStarted()
+            verify(listener).onTransitionFinished()
+        }
+    }
+
+    @Test
+    fun preemptiveStartAndThenSourceStartsAnimationAndSendsProgress_sendsProgress() {
+        provider.preemptivelyStartTransition()
+
+        source.onTransitionStarted()
+        source.onTransitionProgress(0.4f)
+
+        verify(listener).onTransitionProgress(0.4f)
+    }
+
+    @Test
+    fun preemptiveStartAndThenSourceSendsProgress_sendsProgress() {
+        provider.preemptivelyStartTransition()
+
+        source.onTransitionProgress(0.4f)
+
+        verify(listener).onTransitionProgress(0.4f)
+    }
+
+    @Test
+    fun preemptiveStartAfterTransitionRunning_transitionStarted() {
+        source.onTransitionStarted()
+
+        provider.preemptivelyStartTransition()
+
+        verify(listener).onTransitionStarted()
+    }
+
+    @Test
+    fun preemptiveStartAfterTransitionRunningAndThenFinished_transitionFinishes() {
+        source.onTransitionStarted()
+
+        provider.preemptivelyStartTransition()
+        source.onTransitionFinished()
+
+        with(inOrder(listener)) {
+            verify(listener).onTransitionStarted()
+            verify(listener).onTransitionFinished()
+        }
+    }
+
+    @Test
+    fun preemptiveStart_transitionDoesNotFinishAfterTimeout_finishesTransition() {
+        provider.preemptivelyStartTransition()
+
+        testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS + 1)
+        testableLooper.processAllMessages()
+
+        with(inOrder(listener)) {
+            verify(listener).onTransitionStarted()
+            verify(listener).onTransitionFinished()
+        }
+    }
+
+    @Test
+    fun preemptiveStart_transitionFinishAfterTimeout_logsWtf() {
+        provider.preemptivelyStartTransition()
+
+        testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS + 1)
+        testableLooper.processAllMessages()
+
+        verify(testWtfHandler).onTerribleFailure(any(), any(), anyBoolean())
+    }
+
+    @Test
+    fun preemptiveStart_transitionDoesNotFinishBeforeTimeout_doesNotFinishTransition() {
+        provider.preemptivelyStartTransition()
+
+        testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS - 1)
+        testableLooper.processAllMessages()
+
+        verify(listener).onTransitionStarted()
+    }
+
+    @Test
+    fun preemptiveStart_transitionStarted_timeoutHappened_doesNotFinishTransition() {
+        provider.preemptivelyStartTransition()
+
+        source.onTransitionStarted()
+        testableLooper.moveTimeForward(PREEMPTIVE_UNFOLD_TIMEOUT_MS + 1)
+        testableLooper.processAllMessages()
+
+        verify(listener).onTransitionStarted()
+    }
+
+    @Test
+    fun noPreemptiveStart_transitionStarted_startsTransition() {
+        source.onTransitionStarted()
+
+        verify(listener).onTransitionStarted()
+    }
+
+    @Test
+    fun noPreemptiveStart_transitionProgress_sendsProgress() {
+        source.onTransitionStarted()
+
+        source.onTransitionProgress(0.5f)
+
+        verify(listener).onTransitionProgress(0.5f)
+    }
+
+    @Test
+    fun noPreemptiveStart_transitionFinishes_finishesTransition() {
+        source.onTransitionStarted()
+        source.onTransitionProgress(0.5f)
+
+        source.onTransitionFinished()
+
+        with(inOrder(listener)) {
+            verify(listener).onTransitionStarted()
+            verify(listener).onTransitionFinished()
+        }
+    }
+
+    private fun createSource(): UnfoldTransitionProgressProvider =
+        object : TransitionProgressListener, UnfoldTransitionProgressProvider {
+
+            private val listeners = arrayListOf<TransitionProgressListener>()
+
+            override fun addCallback(listener: TransitionProgressListener) {
+                listeners += listener
+            }
+
+            override fun removeCallback(listener: TransitionProgressListener) {
+                listeners -= listener
+            }
+
+            override fun destroy() {}
+
+            override fun onTransitionStarted() =
+                listeners.forEach(TransitionProgressListener::onTransitionStarted)
+
+            override fun onTransitionFinishing() =
+                listeners.forEach(TransitionProgressListener::onTransitionFinishing)
+
+            override fun onTransitionFinished() =
+                listeners.forEach(TransitionProgressListener::onTransitionFinished)
+
+            override fun onTransitionProgress(progress: Float) =
+                listeners.forEach { it.onTransitionProgress(progress) }
+        }
+}
diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java
index cdebe44..a78846a 100644
--- a/src/com/android/launcher3/config/FeatureFlags.java
+++ b/src/com/android/launcher3/config/FeatureFlags.java
@@ -307,6 +307,12 @@
             "Enables receiving unfold animation events from sysui instead of calculating "
                     + "them in launcher process using hinge sensor values.");
 
+    public static final BooleanFlag PREEMPTIVE_UNFOLD_ANIMATION_START = getDebugFlag(270397209,
+            "PREEMPTIVE_UNFOLD_ANIMATION_START", ENABLED,
+            "Enables starting the unfold animation preemptively when unfolding, without"
+                    + "waiting for SystemUI and then merging the SystemUI progress whenever we "
+                    + "start receiving the events");
+
     // TODO(Block 23): Clean up flags
     public static final BooleanFlag ENABLE_GRID_ONLY_OVERVIEW = getDebugFlag(270397206,
             "ENABLE_GRID_ONLY_OVERVIEW", DISABLED,