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

Commit 10254b56 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Extending the SeekableSliderTracker to support Select & Arrow operations

The class now supports the complete state machine that allows a slider
to be controlled by non-touch input such as physical keys. To ensure
that the haptic brightness slider is not triggerd by adaptice brighness,
any non-user progress changes in the slider are ignored.

Test: atest SystemUITests:SeekableSliderTrackerTest
Bug: 316953430
Flag: NONE
Change-Id: If9eda04d4c2acd8436556985b36b150cfbf297f6
parent aef9221b
Loading
Loading
Loading
Loading
+55 −2
Original line number Diff line number Diff line
@@ -58,7 +58,7 @@ class SeekableSliderTracker(

    override suspend fun iterateState(event: SliderEvent) {
        when (currentState) {
            SliderState.IDLE -> handleIdle(event.type)
            SliderState.IDLE -> handleIdle(event.type, event.currentProgress)
            SliderState.WAIT -> handleWait(event.type, event.currentProgress)
            SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> handleAcquired(event.type)
            SliderState.DRAG_HANDLE_DRAGGING -> handleDragging(event.type, event.currentProgress)
@@ -67,17 +67,26 @@ class SeekableSliderTracker(
            SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> setState(SliderState.IDLE)
            SliderState.JUMP_TRACK_LOCATION_SELECTED -> handleJumpToTrack(event.type)
            SliderState.JUMP_BOOKEND_SELECTED -> handleJumpToBookend(event.type)
            SliderState.ARROW_HANDLE_MOVED_ONCE -> handleArrowOnce(event.type)
            SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY ->
                handleArrowContinuous(event.type, event.currentProgress)
            SliderState.ARROW_HANDLE_REACHED_BOOKEND -> handleArrowBookend()
        }
        latestProgress = event.currentProgress
    }

    private fun handleIdle(newEventType: SliderEventType) {
    private fun handleIdle(newEventType: SliderEventType, currentProgress: Float) {
        if (newEventType == SliderEventType.STARTED_TRACKING_TOUCH) {
            timerJob = launchTimer()
            // The WAIT state will wait for the timer to complete or a slider progress to occur.
            // This will disambiguate between an imprecise touch that acquires the slider handle,
            // and a select and jump operation in the slider track.
            setState(SliderState.WAIT)
        } else if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_PROGRAM) {
            val state =
                if (bookendReached(currentProgress)) SliderState.ARROW_HANDLE_REACHED_BOOKEND
                else SliderState.ARROW_HANDLE_MOVED_ONCE
            setState(state)
        }
    }

@@ -176,6 +185,13 @@ class SeekableSliderTracker(
            SliderState.DRAG_HANDLE_REACHED_BOOKEND -> executeOnBookend()
            SliderState.JUMP_TRACK_LOCATION_SELECTED ->
                sliderListener.onProgressJump(latestProgress)
            SliderState.ARROW_HANDLE_MOVED_ONCE -> sliderListener.onSelectAndArrow(latestProgress)
            SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY -> sliderListener.onProgress(latestProgress)
            SliderState.ARROW_HANDLE_REACHED_BOOKEND -> {
                executeOnBookend()
                // This transitory execution must also reset the state
                resetState()
            }
            else -> {}
        }
    }
@@ -204,6 +220,43 @@ class SeekableSliderTracker(
            currentProgress <= config.lowerBookendThreshold
    }

    private fun handleArrowOnce(newEventType: SliderEventType) {
        val nextState =
            when (newEventType) {
                SliderEventType.STARTED_TRACKING_TOUCH -> {
                    // Launching the timer and going to WAIT
                    timerJob = launchTimer()
                    SliderState.WAIT
                }
                SliderEventType.PROGRESS_CHANGE_BY_PROGRAM ->
                    SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY
                SliderEventType.ARROW_UP -> SliderState.IDLE
                else -> SliderState.ARROW_HANDLE_MOVED_ONCE
            }
        setState(nextState)
    }

    private fun handleArrowContinuous(newEventType: SliderEventType, currentProgress: Float) {
        val reachedBookend = bookendReached(currentProgress)
        val nextState =
            when (newEventType) {
                SliderEventType.ARROW_UP -> SliderState.IDLE
                SliderEventType.STARTED_TRACKING_TOUCH -> {
                    // Launching the timer and going to WAIT
                    timerJob = launchTimer()
                    SliderState.WAIT
                }
                SliderEventType.PROGRESS_CHANGE_BY_PROGRAM -> {
                    if (reachedBookend) SliderState.ARROW_HANDLE_REACHED_BOOKEND
                    else SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY
                }
                else -> SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY
            }
        setState(nextState)
    }

    private fun handleArrowBookend() = setState(SliderState.IDLE)

    @VisibleForTesting
    fun setState(state: SliderState) {
        currentState = state
+7 −1
Original line number Diff line number Diff line
@@ -32,6 +32,12 @@ enum class SliderState {
    DRAG_HANDLE_REACHED_BOOKEND,
    /* A location in the slider track has been selected. */
    JUMP_TRACK_LOCATION_SELECTED,
    /* The slider handled moved to a bookend after it was selected. */
    /* The slider handle moved to a bookend after it was selected. */
    JUMP_BOOKEND_SELECTED,
    /** The slider handle moved due to single select-and-arrow operation */
    ARROW_HANDLE_MOVED_ONCE,
    /** The slider handle moves continuously due to constant select-and-arrow operations */
    ARROW_HANDLE_MOVES_CONTINUOUSLY,
    /** The slider handle reached a bookend due to a select-and-arrow operation */
    ARROW_HANDLE_REACHED_BOOKEND,
}
+1 −1
Original line number Diff line number Diff line
@@ -227,7 +227,7 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
                mListener.onChanged(mTracking, progress, false);
                SeekableSliderEventProducer eventProducer =
                        mBrightnessSliderHapticPlugin.getSeekableSliderEventProducer();
                if (eventProducer != null) {
                if (eventProducer != null && fromUser) {
                    eventProducer.onProgressChanged(seekBar, progress, fromUser);
                }
            }
+188 −0
Original line number Diff line number Diff line
@@ -528,6 +528,194 @@ class SeekableSliderTrackerTest : SysuiTestCase() {
        verifyNoMoreInteractions(sliderStateListener)
    }

    @Test
    fun onProgressChangeByProgram_atTheMiddle_onIdle_movesToArrowHandleMovedOnce() = runTest {
        // GIVEN an initialized tracker in the IDLE state
        initTracker(testScheduler)

        // GIVEN a progress due to an external source that lands at the middle of the slider
        val progress = 0.5f
        sliderEventProducer.sendEvent(
            SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
        )

        // THEN the state moves to ARROW_HANDLE_MOVED_ONCE and the listener is called to play
        // haptics
        assertThat(mSeekableSliderTracker.currentState)
            .isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE)
        verify(sliderStateListener).onSelectAndArrow(progress)
    }

    @Test
    fun onProgressChangeByProgram_atUpperBookend_onIdle_movesToIdle() = runTest {
        // GIVEN an initialized tracker in the IDLE state
        val config = SeekableSliderTrackerConfig()
        initTracker(testScheduler, config)

        // GIVEN a progress due to an external source that lands at the upper bookend
        val progress = config.upperBookendThreshold + 0.01f
        sliderEventProducer.sendEvent(
            SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
        )

        // THEN the tracker executes upper bookend haptics before moving back to IDLE
        verify(sliderStateListener).onUpperBookend()
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE)
    }

    @Test
    fun onProgressChangeByProgram_atLowerBookend_onIdle_movesToIdle() = runTest {
        // GIVEN an initialized tracker in the IDLE state
        val config = SeekableSliderTrackerConfig()
        initTracker(testScheduler, config)

        // WHEN a progress is recorded due to an external source that lands at the lower bookend
        val progress = config.lowerBookendThreshold - 0.01f
        sliderEventProducer.sendEvent(
            SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
        )

        // THEN the tracker executes lower bookend haptics before moving to IDLE
        verify(sliderStateListener).onLowerBookend()
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE)
    }

    @Test
    fun onArrowUp_onArrowMovedOnce_movesToIdle() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state
        initTracker(testScheduler)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVED_ONCE)

        // WHEN the external stimulus is released
        val progress = 0.5f
        sliderEventProducer.sendEvent(SliderEvent(SliderEventType.ARROW_UP, progress))

        // THEN the tracker moves back to IDLE and there are no haptics
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE)
        verifyZeroInteractions(sliderStateListener)
    }

    @Test
    fun onStartTrackingTouch_onArrowMovedOnce_movesToWait() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state
        initTracker(testScheduler)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVED_ONCE)

        // WHEN the slider starts tracking touch
        val progress = 0.5f
        sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))

        // THEN the tracker moves back to WAIT and starts the waiting job. Also, there are no
        // haptics
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.WAIT)
        assertThat(mSeekableSliderTracker.isWaiting).isTrue()
        verifyZeroInteractions(sliderStateListener)
    }

    @Test
    fun onProgressChangeByProgram_onArrowMovedOnce_movesToArrowMovesContinuously() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state
        initTracker(testScheduler)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVED_ONCE)

        // WHEN the slider gets an external progress change
        val progress = 0.5f
        sliderEventProducer.sendEvent(
            SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
        )

        // THEN the tracker moves to ARROW_HANDLE_MOVES_CONTINUOUSLY and calls the appropriate
        // haptics
        assertThat(mSeekableSliderTracker.currentState)
            .isEqualTo(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
        verify(sliderStateListener).onProgress(progress)
    }

    @Test
    fun onArrowUp_onArrowMovesContinuously_movesToIdle() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
        initTracker(testScheduler)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)

        // WHEN the external stimulus is released
        val progress = 0.5f
        sliderEventProducer.sendEvent(SliderEvent(SliderEventType.ARROW_UP, progress))

        // THEN the tracker moves to IDLE and no haptics are played
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE)
        verifyZeroInteractions(sliderStateListener)
    }

    @Test
    fun onStartTrackingTouch_onArrowMovesContinuously_movesToWait() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
        initTracker(testScheduler)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)

        // WHEN the slider starts tracking touch
        val progress = 0.5f
        sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress))

        // THEN the tracker moves to WAIT and the wait job starts.
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.WAIT)
        assertThat(mSeekableSliderTracker.isWaiting).isTrue()
        verifyZeroInteractions(sliderStateListener)
    }

    @Test
    fun onProgressChangeByProgram_onArrowMovesContinuously_preservesState() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
        initTracker(testScheduler)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)

        // WHEN the slider changes progress programmatically at the middle
        val progress = 0.5f
        sliderEventProducer.sendEvent(
            SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
        )

        // THEN the tracker stays in the same state and haptics are delivered appropriately
        assertThat(mSeekableSliderTracker.currentState)
            .isEqualTo(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)
        verify(sliderStateListener).onProgress(progress)
    }

    @Test
    fun onProgramProgress_atLowerBookend_onArrowMovesContinuously_movesToIdle() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
        val config = SeekableSliderTrackerConfig()
        initTracker(testScheduler, config)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)

        // WHEN the slider reaches the lower bookend programmatically
        val progress = config.lowerBookendThreshold - 0.01f
        sliderEventProducer.sendEvent(
            SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
        )

        // THEN the tracker executes lower bookend haptics before moving to IDLE
        verify(sliderStateListener).onLowerBookend()
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE)
    }

    @Test
    fun onProgramProgress_atUpperBookend_onArrowMovesContinuously_movesToIdle() = runTest {
        // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state
        val config = SeekableSliderTrackerConfig()
        initTracker(testScheduler, config)
        mSeekableSliderTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY)

        // WHEN the slider reaches the lower bookend programmatically
        val progress = config.upperBookendThreshold + 0.01f
        sliderEventProducer.sendEvent(
            SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress)
        )

        // THEN the tracker executes upper bookend haptics before moving to IDLE
        verify(sliderStateListener).onUpperBookend()
        assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE)
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private fun initTracker(
        scheduler: TestCoroutineScheduler,