diff --git a/packages/SystemUI/shared/src/com/android/systemui/unfold/progress/PhysicsBasedUnfoldTransitionProgressProvider.kt b/packages/SystemUI/shared/src/com/android/systemui/unfold/progress/PhysicsBasedUnfoldTransitionProgressProvider.kt index 5266f096e12ce..3daae75aaed7d 100644 --- a/packages/SystemUI/shared/src/com/android/systemui/unfold/progress/PhysicsBasedUnfoldTransitionProgressProvider.kt +++ b/packages/SystemUI/shared/src/com/android/systemui/unfold/progress/PhysicsBasedUnfoldTransitionProgressProvider.kt @@ -33,7 +33,7 @@ import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdate import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdatesListener /** Maps fold updates to unfold transition progress using DynamicAnimation. */ -internal class PhysicsBasedUnfoldTransitionProgressProvider( +class PhysicsBasedUnfoldTransitionProgressProvider( private val foldStateProvider: FoldStateProvider ) : UnfoldTransitionProgressProvider, FoldUpdatesListener, DynamicAnimation.OnAnimationEndListener { @@ -97,7 +97,17 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( FOLD_UPDATE_START_CLOSING -> { // The transition might be already running as the device might start closing several // times before reaching an end state. - if (!isTransitionRunning) { + if (isTransitionRunning) { + // If we are cancelling the animation, reset that so we can resume it normally. + // The animation could be 'cancelled' when the user stops folding/unfolding + // for some period of time or fully unfolds the device. In this case, + // it is forced to run to the end ignoring all further hinge angle events. + // By resetting this flag we allow reacting to hinge angle events again, so + // the transition continues running. + if (isAnimatedCancelRunning) { + isAnimatedCancelRunning = false + } + } else { startTransition(startValue = 1f) } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/unfold/progress/PhysicsBasedUnfoldTransitionProgressProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/unfold/progress/PhysicsBasedUnfoldTransitionProgressProviderTest.kt new file mode 100644 index 0000000000000..5509a6cac1ddc --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/unfold/progress/PhysicsBasedUnfoldTransitionProgressProviderTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2022 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.systemui.unfold.progress + +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.systemui.SysuiTestCase +import com.android.systemui.unfold.UnfoldTransitionProgressProvider +import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener +import com.android.systemui.unfold.updates.FOLD_UPDATE_START_OPENING +import com.android.systemui.unfold.updates.FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE +import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_FULL_OPEN +import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_HALF_OPEN +import com.android.systemui.unfold.updates.FOLD_UPDATE_START_CLOSING +import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_CLOSED +import com.android.systemui.unfold.util.TestFoldStateProvider +import com.android.systemui.util.leak.ReferenceTestUtils.waitForCondition +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidTestingRunner::class) +@SmallTest +class PhysicsBasedUnfoldTransitionProgressProviderTest : SysuiTestCase() { + + private val foldStateProvider: TestFoldStateProvider = TestFoldStateProvider() + private val listener = TestUnfoldProgressListener() + private lateinit var progressProvider: UnfoldTransitionProgressProvider + + @Before + fun setUp() { + progressProvider = PhysicsBasedUnfoldTransitionProgressProvider( + foldStateProvider + ) + progressProvider.addCallback(listener) + } + + @Test + fun testUnfold_emitsIncreasingTransitionEvents() { + runOnMainThreadWithInterval( + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_START_OPENING) }, + { foldStateProvider.sendHingeAngleUpdate(10f) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE) }, + { foldStateProvider.sendHingeAngleUpdate(90f) }, + { foldStateProvider.sendHingeAngleUpdate(180f) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN) }, + ) + + with(listener.ensureTransitionFinished()) { + assertIncreasingProgress() + assertFinishedWithUnfold() + } + } + + @Test + fun testUnfold_screenAvailableOnlyAfterFullUnfold_emitsIncreasingTransitionEvents() { + runOnMainThreadWithInterval( + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_START_OPENING) }, + { foldStateProvider.sendHingeAngleUpdate(10f) }, + { foldStateProvider.sendHingeAngleUpdate(90f) }, + { foldStateProvider.sendHingeAngleUpdate(180f) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE) }, + ) + + with(listener.ensureTransitionFinished()) { + assertIncreasingProgress() + assertFinishedWithUnfold() + } + } + + @Test + fun testFold_emitsDecreasingTransitionEvents() { + runOnMainThreadWithInterval( + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_START_CLOSING) }, + { foldStateProvider.sendHingeAngleUpdate(170f) }, + { foldStateProvider.sendHingeAngleUpdate(90f) }, + { foldStateProvider.sendHingeAngleUpdate(10f) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_FINISH_CLOSED) }, + ) + + with(listener.ensureTransitionFinished()) { + assertDecreasingProgress() + assertFinishedWithFold() + } + } + + @Test + fun testUnfoldAndStopUnfolding_finishesTheUnfoldTransition() { + runOnMainThreadWithInterval( + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_START_OPENING) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE) }, + { foldStateProvider.sendHingeAngleUpdate(10f) }, + { foldStateProvider.sendHingeAngleUpdate(90f) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_FINISH_HALF_OPEN) }, + ) + + with(listener.ensureTransitionFinished()) { + assertIncreasingProgress() + assertFinishedWithUnfold() + } + } + + @Test + fun testFoldImmediatelyAfterUnfold_runsFoldAnimation() { + runOnMainThreadWithInterval( + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_START_OPENING) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE) }, + { foldStateProvider.sendHingeAngleUpdate(10f) }, + { foldStateProvider.sendHingeAngleUpdate(90f) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_START_CLOSING) }, + { foldStateProvider.sendHingeAngleUpdate(60f) }, + { foldStateProvider.sendHingeAngleUpdate(10f) }, + { foldStateProvider.sendFoldUpdate(FOLD_UPDATE_FINISH_CLOSED) }, + ) + + with(listener.ensureTransitionFinished()) { + assertHasFoldAnimationAtTheEnd() + } + } + + private class TestUnfoldProgressListener : TransitionProgressListener { + + private val recordings: MutableList = arrayListOf() + private var currentRecording: UnfoldTransitionRecording? = null + + override fun onTransitionStarted() { + assertWithMessage("Trying to start a transition when it is already in progress") + .that(currentRecording).isNull() + + currentRecording = UnfoldTransitionRecording() + } + + override fun onTransitionProgress(progress: Float) { + assertWithMessage("Received transition progress event when it's not started") + .that(currentRecording).isNotNull() + currentRecording!!.addProgress(progress) + } + + override fun onTransitionFinished() { + assertWithMessage("Received transition finish event when it's not started") + .that(currentRecording).isNotNull() + recordings += currentRecording!! + currentRecording = null + } + + fun ensureTransitionFinished(): UnfoldTransitionRecording { + waitForCondition { recordings.size == 1 } + return recordings.first() + } + + class UnfoldTransitionRecording { + private val progressHistory: MutableList = arrayListOf() + + fun addProgress(progress: Float) { + assertThat(progress).isAtMost(1.0f) + assertThat(progress).isAtLeast(0.0f) + + progressHistory += progress + } + + fun assertIncreasingProgress() { + assertThat(progressHistory.size).isGreaterThan(MIN_ANIMATION_EVENTS) + assertThat(progressHistory).isInOrder() + } + + fun assertDecreasingProgress() { + assertThat(progressHistory.size).isGreaterThan(MIN_ANIMATION_EVENTS) + assertThat(progressHistory).isInOrder(Comparator.reverseOrder()) + } + + fun assertFinishedWithUnfold() { + assertThat(progressHistory).isNotEmpty() + assertThat(progressHistory.last()).isEqualTo(1.0f) + } + + fun assertFinishedWithFold() { + assertThat(progressHistory).isNotEmpty() + assertThat(progressHistory.last()).isEqualTo(0.0f) + } + + fun assertHasFoldAnimationAtTheEnd() { + // Check that there are at least a few decreasing events at the end + assertThat(progressHistory.size).isGreaterThan(MIN_ANIMATION_EVENTS) + assertThat(progressHistory.takeLast(MIN_ANIMATION_EVENTS)) + .isInOrder(Comparator.reverseOrder()) + assertThat(progressHistory.last()).isEqualTo(0.0f) + } + } + + private companion object { + private const val MIN_ANIMATION_EVENTS = 5 + } + } + + private fun runOnMainThreadWithInterval(vararg blocks: () -> Unit, intervalMillis: Long = 60) { + blocks.forEach { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + it() + } + Thread.sleep(intervalMillis) + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/unfold/util/TestFoldStateProvider.kt b/packages/SystemUI/tests/src/com/android/systemui/unfold/util/TestFoldStateProvider.kt new file mode 100644 index 0000000000000..dd307b4b21e99 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/unfold/util/TestFoldStateProvider.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2022 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.systemui.unfold.util + +import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_FULL_OPEN +import com.android.systemui.unfold.updates.FoldStateProvider +import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdate +import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdatesListener + +class TestFoldStateProvider : FoldStateProvider { + + private val listeners: MutableList = arrayListOf() + + override fun start() { + } + + override fun stop() { + listeners.clear() + } + + private var _isFullyOpened: Boolean = false + + override val isFullyOpened: Boolean + get() = _isFullyOpened + + override fun addCallback(listener: FoldUpdatesListener) { + listeners += listener + } + + override fun removeCallback(listener: FoldUpdatesListener) { + listeners -= listener + } + + fun sendFoldUpdate(@FoldUpdate update: Int) { + if (update == FOLD_UPDATE_FINISH_FULL_OPEN) { + _isFullyOpened = true + } + listeners.forEach { it.onFoldUpdate(update) } + } + + fun sendHingeAngleUpdate(angle: Float) { + listeners.forEach { it.onHingeAngleUpdate(angle) } + } +}