diff --git a/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java b/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java index ad11b7e953..328a7270eb 100644 --- a/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java +++ b/quickstep/src/com/android/quickstep/util/BaseUnfoldMoveFromCenterAnimator.java @@ -43,6 +43,10 @@ public abstract class BaseUnfoldMoveFromCenterAnimator implements TransitionProg 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 @@ public abstract class BaseUnfoldMoveFromCenterAnimator implements TransitionProg @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 @@ public abstract class BaseUnfoldMoveFromCenterAnimator implements TransitionProg 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 8fdafc6059..6d15e8be98 100644 --- a/quickstep/src/com/android/quickstep/util/LauncherUnfoldAnimationController.java +++ b/quickstep/src/com/android/quickstep/util/LauncherUnfoldAnimationController.java @@ -27,10 +27,15 @@ import android.view.WindowManager; 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 @@ import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider; /** * 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 @@ public class LauncherUnfoldAnimationController { 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 @@ public class LauncherUnfoldAnimationController { 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 @@ public class LauncherUnfoldAnimationController { // Animated only in natural orientation mNaturalOrientationProgressProvider.addCallback(new QsbAnimationListener()); mNaturalOrientationProgressProvider.addCallback(mUnfoldMoveFromCenterHotseatAnimator); + + mLauncher.addOnDeviceProfileChangeListener(this); } /** @@ -96,17 +116,21 @@ public class LauncherUnfoldAnimationController { 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 class LauncherUnfoldAnimationController { 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 70a12d6435..c8141b4642 100644 --- a/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterHotseatAnimator.java +++ b/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterHotseatAnimator.java @@ -48,6 +48,8 @@ public class UnfoldMoveFromCenterHotseatAnimator extends BaseUnfoldMoveFromCente 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 7da103ee58..c05b38f5f2 100644 --- a/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterWorkspaceAnimator.java +++ b/quickstep/src/com/android/quickstep/util/UnfoldMoveFromCenterWorkspaceAnimator.java @@ -58,6 +58,8 @@ public class UnfoldMoveFromCenterWorkspaceAnimator extends BaseUnfoldMoveFromCen 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 0000000000..a9cd0484fd --- /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() + 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 0000000000..f73be7269d --- /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() + + 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 331ae5da65..fef66390a4 100644 --- a/src/com/android/launcher3/config/FeatureFlags.java +++ b/src/com/android/launcher3/config/FeatureFlags.java @@ -314,6 +314,12 @@ public final class FeatureFlags { "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,