diff options
5 files changed, 229 insertions, 58 deletions
diff --git a/packages/SystemUI/shared/src/com/android/systemui/unfold/UnfoldTransitionFactory.kt b/packages/SystemUI/shared/src/com/android/systemui/unfold/UnfoldTransitionFactory.kt index e46b6f12e4a3..ea93a3b9375e 100644 --- a/packages/SystemUI/shared/src/com/android/systemui/unfold/UnfoldTransitionFactory.kt +++ b/packages/SystemUI/shared/src/com/android/systemui/unfold/UnfoldTransitionFactory.kt @@ -60,14 +60,12 @@ fun createUnfoldTransitionProgressProvider( hingeAngleProvider, screenStatusProvider, deviceStateManager, - mainExecutor + mainExecutor, + mainHandler ) val unfoldTransitionProgressProvider = if (config.isHingeAngleEnabled) { - PhysicsBasedUnfoldTransitionProgressProvider( - mainHandler, - foldStateProvider - ) + PhysicsBasedUnfoldTransitionProgressProvider(foldStateProvider) } else { FixedTimingTransitionProgressProvider(foldStateProvider) } 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 90f5998053b8..51eae573f040 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 @@ -15,7 +15,6 @@ */ package com.android.systemui.unfold.progress -import android.os.Handler import android.util.Log import android.util.MathUtils.saturate import androidx.dynamicanimation.animation.DynamicAnimation @@ -24,9 +23,10 @@ import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce import com.android.systemui.unfold.UnfoldTransitionProgressProvider import com.android.systemui.unfold.UnfoldTransitionProgressProvider.TransitionProgressListener +import com.android.systemui.unfold.updates.FOLD_UPDATE_ABORTED import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_CLOSED -import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_FULL_OPEN import com.android.systemui.unfold.updates.FOLD_UPDATE_START_CLOSING +import com.android.systemui.unfold.updates.FOLD_UPDATE_FINISH_FULL_OPEN import com.android.systemui.unfold.updates.FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE import com.android.systemui.unfold.updates.FoldStateProvider import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdate @@ -39,7 +39,6 @@ import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdatesListener * - doesn't handle postures */ internal class PhysicsBasedUnfoldTransitionProgressProvider( - private val handler: Handler, private val foldStateProvider: FoldStateProvider ) : UnfoldTransitionProgressProvider, @@ -51,8 +50,6 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( addEndListener(this@PhysicsBasedUnfoldTransitionProgressProvider) } - private val timeoutRunnable = TimeoutRunnable() - private var isTransitionRunning = false private var isAnimatedCancelRunning = false @@ -92,7 +89,7 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( cancelTransition(endValue = 1f, animate = true) } } - FOLD_UPDATE_FINISH_FULL_OPEN -> { + FOLD_UPDATE_FINISH_FULL_OPEN, FOLD_UPDATE_ABORTED -> { // Do not cancel if we haven't started the transition yet. // This could happen when we fully unfolded the device before the screen // became available. In this case we start and immediately cancel the animation @@ -106,7 +103,11 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( cancelTransition(endValue = 0f, animate = false) } FOLD_UPDATE_START_CLOSING -> { - startTransition(startValue = 1f) + // The transition might be already running as the device might start closing several + // times before reaching an end state. + if (!isTransitionRunning) { + startTransition(startValue = 1f) + } } } @@ -116,8 +117,6 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( } private fun cancelTransition(endValue: Float, animate: Boolean) { - handler.removeCallbacks(timeoutRunnable) - if (isTransitionRunning && animate) { isAnimatedCancelRunning = true springAnimation.animateToFinalPosition(endValue) @@ -175,8 +174,6 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( } springAnimation.start() - - handler.postDelayed(timeoutRunnable, TRANSITION_TIMEOUT_MILLIS) } override fun addCallback(listener: TransitionProgressListener) { @@ -187,13 +184,6 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( listeners.remove(listener) } - private inner class TimeoutRunnable : Runnable { - - override fun run() { - cancelTransition(endValue = 1f, animate = true) - } - } - private object AnimationProgressProperty : FloatPropertyCompat<PhysicsBasedUnfoldTransitionProgressProvider>("animation_progress") { @@ -212,7 +202,6 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider( private const val TAG = "PhysicsBasedUnfoldTransitionProgressProvider" private const val DEBUG = true -private const val TRANSITION_TIMEOUT_MILLIS = 2000L private const val SPRING_STIFFNESS = 200.0f private const val MINIMAL_VISIBLE_CHANGE = 0.001f private const val FINAL_HINGE_ANGLE_POSITION = 165f diff --git a/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/DeviceFoldStateProvider.kt b/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/DeviceFoldStateProvider.kt index 35e2b30d0a39..6d9631c12430 100644 --- a/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/DeviceFoldStateProvider.kt +++ b/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/DeviceFoldStateProvider.kt @@ -15,14 +15,19 @@ */ package com.android.systemui.unfold.updates +import android.annotation.FloatRange import android.content.Context import android.hardware.devicestate.DeviceStateManager +import android.os.Handler +import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.core.util.Consumer -import com.android.systemui.unfold.updates.screen.ScreenStatusProvider import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdate import com.android.systemui.unfold.updates.FoldStateProvider.FoldUpdatesListener +import com.android.systemui.unfold.updates.hinge.FULLY_CLOSED_DEGREES import com.android.systemui.unfold.updates.hinge.FULLY_OPEN_DEGREES import com.android.systemui.unfold.updates.hinge.HingeAngleProvider +import com.android.systemui.unfold.updates.screen.ScreenStatusProvider import java.util.concurrent.Executor class DeviceFoldStateProvider( @@ -30,7 +35,8 @@ class DeviceFoldStateProvider( private val hingeAngleProvider: HingeAngleProvider, private val screenStatusProvider: ScreenStatusProvider, private val deviceStateManager: DeviceStateManager, - private val mainExecutor: Executor + private val mainExecutor: Executor, + private val handler: Handler ) : FoldStateProvider { private val outputListeners: MutableList<FoldUpdatesListener> = mutableListOf() @@ -38,9 +44,13 @@ class DeviceFoldStateProvider( @FoldUpdate private var lastFoldUpdate: Int? = null + @FloatRange(from = 0.0, to = 180.0) + private var lastHingeAngle: Float = 0f + private val hingeAngleListener = HingeAngleListener() private val screenListener = ScreenStatusListener() private val foldStateListener = FoldStateListener(context) + private val timeoutRunnable = TimeoutRunnable() private var isFolded = false private var isUnfoldHandled = true @@ -72,47 +82,69 @@ class DeviceFoldStateProvider( override val isFullyOpened: Boolean get() = !isFolded && lastFoldUpdate == FOLD_UPDATE_FINISH_FULL_OPEN + private val isTransitionInProgess: Boolean + get() = lastFoldUpdate == FOLD_UPDATE_START_OPENING || + lastFoldUpdate == FOLD_UPDATE_START_CLOSING + private fun onHingeAngle(angle: Float) { - when (lastFoldUpdate) { - FOLD_UPDATE_FINISH_FULL_OPEN -> { - if (FULLY_OPEN_DEGREES - angle > START_CLOSING_THRESHOLD_DEGREES) { - lastFoldUpdate = FOLD_UPDATE_START_CLOSING - outputListeners.forEach { it.onFoldUpdate(FOLD_UPDATE_START_CLOSING) } - } - } - FOLD_UPDATE_START_OPENING -> { - if (FULLY_OPEN_DEGREES - angle < FULLY_OPEN_THRESHOLD_DEGREES) { - lastFoldUpdate = FOLD_UPDATE_FINISH_FULL_OPEN - outputListeners.forEach { it.onFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN) } - } - } - FOLD_UPDATE_START_CLOSING -> { - if (FULLY_OPEN_DEGREES - angle < START_CLOSING_THRESHOLD_DEGREES) { - lastFoldUpdate = FOLD_UPDATE_FINISH_FULL_OPEN - outputListeners.forEach { it.onFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN) } - } + if (DEBUG) { Log.d(TAG, "Hinge angle: $angle, lastHingeAngle: $lastHingeAngle") } + + val isClosing = angle < lastHingeAngle + val isFullyOpened = FULLY_OPEN_DEGREES - angle < FULLY_OPEN_THRESHOLD_DEGREES + val closingEventDispatched = lastFoldUpdate == FOLD_UPDATE_START_CLOSING + + if (isClosing && !closingEventDispatched && !isFullyOpened) { + notifyFoldUpdate(FOLD_UPDATE_START_CLOSING) + } + + if (isTransitionInProgess) { + if (isFullyOpened) { + notifyFoldUpdate(FOLD_UPDATE_FINISH_FULL_OPEN) + cancelTimeout() + } else { + // The timeout will trigger some constant time after the last angle update. + rescheduleAbortAnimationTimeout() } } + lastHingeAngle = angle outputListeners.forEach { it.onHingeAngleUpdate(angle) } } private inner class FoldStateListener(context: Context) : DeviceStateManager.FoldStateListener(context, { folded: Boolean -> isFolded = folded + lastHingeAngle = FULLY_CLOSED_DEGREES if (folded) { - lastFoldUpdate = FOLD_UPDATE_FINISH_CLOSED - outputListeners.forEach { it.onFoldUpdate(FOLD_UPDATE_FINISH_CLOSED) } hingeAngleProvider.stop() + notifyFoldUpdate(FOLD_UPDATE_FINISH_CLOSED) + cancelTimeout() isUnfoldHandled = false } else { - lastFoldUpdate = FOLD_UPDATE_START_OPENING - outputListeners.forEach { it.onFoldUpdate(FOLD_UPDATE_START_OPENING) } + notifyFoldUpdate(FOLD_UPDATE_START_OPENING) + rescheduleAbortAnimationTimeout() hingeAngleProvider.start() } }) + private fun notifyFoldUpdate(@FoldUpdate update: Int) { + if (DEBUG) { Log.d(TAG, stateToString(update)) } + outputListeners.forEach { it.onFoldUpdate(update) } + lastFoldUpdate = update + } + + private fun rescheduleAbortAnimationTimeout() { + if (isTransitionInProgess) { + cancelTimeout() + } + handler.postDelayed(timeoutRunnable, ABORT_CLOSING_MILLIS) + } + + private fun cancelTimeout() { + handler.removeCallbacks(timeoutRunnable) + } + private inner class ScreenStatusListener : ScreenStatusProvider.ScreenListener { @@ -136,7 +168,39 @@ class DeviceFoldStateProvider( onHingeAngle(angle) } } + + private inner class TimeoutRunnable : Runnable { + + override fun run() { + notifyFoldUpdate(FOLD_UPDATE_ABORTED) + } + } +} + +private fun stateToString(@FoldUpdate update: Int): String { + return when (update) { + FOLD_UPDATE_START_OPENING -> "START_OPENING" + FOLD_UPDATE_HALF_OPEN -> "HALF_OPEN" + FOLD_UPDATE_START_CLOSING -> "START_CLOSING" + FOLD_UPDATE_ABORTED -> "ABORTED" + FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE -> "UNFOLDED_SCREEN_AVAILABLE" + FOLD_UPDATE_FINISH_HALF_OPEN -> "FINISH_HALF_OPEN" + FOLD_UPDATE_FINISH_FULL_OPEN -> "FINISH_FULL_OPEN" + FOLD_UPDATE_FINISH_CLOSED -> "FINISH_CLOSED" + else -> "UNKNOWN" + } } -private const val START_CLOSING_THRESHOLD_DEGREES = 95f -private const val FULLY_OPEN_THRESHOLD_DEGREES = 15f
\ No newline at end of file +private const val TAG = "DeviceFoldProvider" +private const val DEBUG = false + +/** + * Time after which [FOLD_UPDATE_ABORTED] is emitted following a [FOLD_UPDATE_START_CLOSING] or + * [FOLD_UPDATE_START_OPENING] event, if an end state is not reached. + */ +@VisibleForTesting +const val ABORT_CLOSING_MILLIS = 1000L + +/** Threshold after which we consider the device fully unfolded. */ +@VisibleForTesting +const val FULLY_OPEN_THRESHOLD_DEGREES = 15f
\ No newline at end of file diff --git a/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/FoldStateProvider.kt b/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/FoldStateProvider.kt index 643ece353522..bffebcd4512b 100644 --- a/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/FoldStateProvider.kt +++ b/packages/SystemUI/shared/src/com/android/systemui/unfold/updates/FoldStateProvider.kt @@ -39,6 +39,7 @@ interface FoldStateProvider : CallbackController<FoldUpdatesListener> { FOLD_UPDATE_START_OPENING, FOLD_UPDATE_HALF_OPEN, FOLD_UPDATE_START_CLOSING, + FOLD_UPDATE_ABORTED, FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE, FOLD_UPDATE_FINISH_HALF_OPEN, FOLD_UPDATE_FINISH_FULL_OPEN, @@ -51,7 +52,8 @@ interface FoldStateProvider : CallbackController<FoldUpdatesListener> { const val FOLD_UPDATE_START_OPENING = 0 const val FOLD_UPDATE_HALF_OPEN = 1 const val FOLD_UPDATE_START_CLOSING = 2 -const val FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE = 3 -const val FOLD_UPDATE_FINISH_HALF_OPEN = 4 -const val FOLD_UPDATE_FINISH_FULL_OPEN = 5 -const val FOLD_UPDATE_FINISH_CLOSED = 6 +const val FOLD_UPDATE_ABORTED = 3 +const val FOLD_UPDATE_UNFOLDED_SCREEN_AVAILABLE = 4 +const val FOLD_UPDATE_FINISH_HALF_OPEN = 5 +const val FOLD_UPDATE_FINISH_FULL_OPEN = 6 +const val FOLD_UPDATE_FINISH_CLOSED = 7 diff --git a/packages/SystemUI/tests/src/com/android/systemui/unfold/updates/DeviceFoldStateProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/unfold/updates/DeviceFoldStateProviderTest.kt index a1d9a7b50d81..be1720d07d11 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/unfold/updates/DeviceFoldStateProviderTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/unfold/updates/DeviceFoldStateProviderTest.kt @@ -18,7 +18,9 @@ package com.android.systemui.unfold.updates import android.hardware.devicestate.DeviceStateManager import android.hardware.devicestate.DeviceStateManager.FoldStateListener +import android.os.Handler import android.testing.AndroidTestingRunner +import androidx.core.util.Consumer import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.unfold.updates.hinge.HingeAngleProvider @@ -31,9 +33,12 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor +import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever import org.mockito.MockitoAnnotations +import java.lang.Exception @RunWith(AndroidTestingRunner::class) @SmallTest @@ -48,16 +53,28 @@ class DeviceFoldStateProviderTest : SysuiTestCase() { @Mock private lateinit var deviceStateManager: DeviceStateManager - private lateinit var foldStateProvider: FoldStateProvider + @Mock + private lateinit var handler: Handler + + @Captor + private lateinit var foldStateListenerCaptor: ArgumentCaptor<FoldStateListener> + + @Captor + private lateinit var screenOnListenerCaptor: ArgumentCaptor<ScreenListener> + + @Captor + private lateinit var hingeAngleCaptor: ArgumentCaptor<Consumer<Float>> + + private lateinit var foldStateProvider: DeviceFoldStateProvider private val foldUpdates: MutableList<Int> = arrayListOf() private val hingeAngleUpdates: MutableList<Float> = arrayListOf() - private val foldStateListenerCaptor = ArgumentCaptor.forClass(FoldStateListener::class.java) private var foldedDeviceState: Int = 0 private var unfoldedDeviceState: Int = 0 - private val screenOnListenerCaptor = ArgumentCaptor.forClass(ScreenListener::class.java) + private var scheduledRunnable: Runnable? = null + private var scheduledRunnableDelay: Long? = null @Before fun setUp() { @@ -75,7 +92,8 @@ class DeviceFoldStateProviderTest : SysuiTestCase() { hingeAngleProvider, screenStatusProvider, deviceStateManager, - context.mainExecutor + context.mainExecutor, + handler ) foldStateProvider.addCallback(object : FoldStateProvider.FoldUpdatesListener { @@ -91,6 +109,22 @@ class DeviceFoldStateProviderTest : SysuiTestCase() { verify(deviceStateManager).registerCallback(any(), foldStateListenerCaptor.capture()) verify(screenStatusProvider).addCallback(screenOnListenerCaptor.capture()) + verify(hingeAngleProvider).addCallback(hingeAngleCaptor.capture()) + + whenever(handler.postDelayed(any<Runnable>(), any())).then { invocationOnMock -> + scheduledRunnable = invocationOnMock.getArgument<Runnable>(0) + scheduledRunnableDelay = invocationOnMock.getArgument<Long>(1) + null + } + + whenever(handler.removeCallbacks(any<Runnable>())).then { invocationOnMock -> + val removedRunnable = invocationOnMock.getArgument<Runnable>(0) + if (removedRunnable == scheduledRunnable) { + scheduledRunnableDelay = null + scheduledRunnable = null + } + null + } } @Test @@ -167,6 +201,86 @@ class DeviceFoldStateProviderTest : SysuiTestCase() { assertThat(foldUpdates).isEmpty() } + @Test + fun startClosingEvent_afterTimeout_abortEmitted() { + sendHingeAngleEvent(90) + sendHingeAngleEvent(80) + + simulateTimeout(ABORT_CLOSING_MILLIS) + + assertThat(foldUpdates).containsExactly(FOLD_UPDATE_START_CLOSING, FOLD_UPDATE_ABORTED) + } + + @Test + fun startClosingEvent_beforeTimeout_abortNotEmitted() { + sendHingeAngleEvent(90) + sendHingeAngleEvent(80) + + simulateTimeout(ABORT_CLOSING_MILLIS - 1) + + assertThat(foldUpdates).containsExactly(FOLD_UPDATE_START_CLOSING) + } + + @Test + fun startClosingEvent_eventBeforeTimeout_oneEventEmitted() { + sendHingeAngleEvent(180) + sendHingeAngleEvent(90) + + simulateTimeout(ABORT_CLOSING_MILLIS - 1) + sendHingeAngleEvent(80) + + assertThat(foldUpdates).containsExactly(FOLD_UPDATE_START_CLOSING) + } + + @Test + fun startClosingEvent_timeoutAfterTimeoutRescheduled_abortEmitted() { + sendHingeAngleEvent(180) + sendHingeAngleEvent(90) + + simulateTimeout(ABORT_CLOSING_MILLIS - 1) // The timeout should not trigger here. + sendHingeAngleEvent(80) + simulateTimeout(ABORT_CLOSING_MILLIS) // The timeout should trigger here. + + assertThat(foldUpdates).containsExactly(FOLD_UPDATE_START_CLOSING, FOLD_UPDATE_ABORTED) + } + + @Test + fun startClosingEvent_shortTimeBetween_emitsOnlyOneEvents() { + sendHingeAngleEvent(180) + + sendHingeAngleEvent(90) + sendHingeAngleEvent(80) + + assertThat(foldUpdates).containsExactly(FOLD_UPDATE_START_CLOSING) + } + + @Test + fun startClosingEvent_whileClosing_emittedDespiteInitialAngle() { + val maxAngle = 180 - FULLY_OPEN_THRESHOLD_DEGREES.toInt() + for (i in 1..maxAngle) { + foldUpdates.clear() + + simulateFolding(startAngle = i) + + assertThat(foldUpdates).containsExactly(FOLD_UPDATE_START_CLOSING) + simulateTimeout() // Timeout to set the state to aborted. + } + } + + private fun simulateTimeout(waitTime: Long = ABORT_CLOSING_MILLIS) { + val runnableDelay = scheduledRunnableDelay ?: throw Exception("No runnable scheduled.") + if (waitTime >= runnableDelay) { + scheduledRunnable?.run() + scheduledRunnable = null + scheduledRunnableDelay = null + } + } + + private fun simulateFolding(startAngle: Int) { + sendHingeAngleEvent(startAngle) + sendHingeAngleEvent(startAngle - 1) + } + private fun setFoldState(folded: Boolean) { val state = if (folded) foldedDeviceState else unfoldedDeviceState foldStateListenerCaptor.value.onStateChanged(state) @@ -175,4 +289,8 @@ class DeviceFoldStateProviderTest : SysuiTestCase() { private fun fireScreenOnEvent() { screenOnListenerCaptor.value.onScreenTurnedOn() } + + private fun sendHingeAngleEvent(angle: Int) { + hingeAngleCaptor.value.accept(angle.toFloat()) + } } |