Improve fold state provider logic handling posture

Now FOLD_UPDATE_START_CLOSING event is emitted when the angle decreases.
After each angle update, a timeout is started to finish the animation.
When the timeout is reached, FOLD_UPDATE_ABORTED is emitted.

The timeout logic has been moved from
PhysicsBasedUnfoldTransitionProgressProvider to DeviceFoldStateProvider.

This change should not create problems to
FixedTimingTransitionProgressProvider, as it doesn't use any
intermediate FOLD_UPDATE event.

Test: Locally on device
Test atest com.android.systemui.unfold.updates.DeviceFoldStateProviderTest
Bug: 208977663
Change-Id: Ibc5df1e089d6543e7cf6ee2e0ce3d48a0b24cc07
This commit is contained in:
Nicolo' Mazzucato
2021-12-03 13:06:45 +01:00
parent 31801ae6ea
commit 5aef2c1fab
5 changed files with 229 additions and 58 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 const val START_CLOSING_THRESHOLD_DEGREES = 95f
private const val FULLY_OPEN_THRESHOLD_DEGREES = 15f
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 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

View File

@@ -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

View File

@@ -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())
}
}