Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 5aef2c1f authored by Nicolo' Mazzucato's avatar Nicolo' Mazzucato
Browse files

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
parent 31801ae6
Loading
Loading
Loading
Loading
+3 −5
Original line number Diff line number Diff line
@@ -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)
    }
+8 −19
Original line number Diff line number Diff line
@@ -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,9 +103,13 @@ internal class PhysicsBasedUnfoldTransitionProgressProvider(
                cancelTransition(endValue = 0f, animate = false)
            }
            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) {
                    startTransition(startValue = 1f)
                }
            }
        }

        if (DEBUG) {
            Log.d(TAG, "onFoldUpdate = $update")
@@ -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
+90 −26
Original line number Diff line number Diff line
@@ -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
+6 −4
Original line number Diff line number Diff line
@@ -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
+122 −4
Original line number Diff line number Diff line
@@ -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())
    }
}