From f00fd4459802a86538e95a509f64ebefdb7fb462 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Martinez Date: Wed, 20 Mar 2024 16:22:14 -0700 Subject: Refactor of the SeekableSliderEventProducer and SeekableSliderTracker. To support more generalized slider haptics, the SeekableSliderEventProducer has been refactored to a SliderStateProducer. The SeekableSliderTracker has also been renamed to better convey its functionality. The only element that remains coupled to an Android SeekBar is the SeekableSliderHapticPlugin. Test: atest SystemUITests Flag: NONE Bug: TBD Change-Id: I62e122fe44582ad4af1608924b09c23ad1a25453 --- .../slider/SeekableSliderHapticPluginTest.kt | 154 ----- .../haptics/slider/SeekbarHapticPluginTest.kt | 154 +++++ .../haptics/slider/SliderStateProducerTest.kt | 134 ++++ .../haptics/slider/HapticSliderViewBinder.kt | 4 +- .../haptics/slider/SeekableSliderEventProducer.kt | 75 --- .../haptics/slider/SeekableSliderHapticPlugin.kt | 168 ----- .../haptics/slider/SeekableSliderTracker.kt | 261 -------- .../systemui/haptics/slider/SeekbarHapticPlugin.kt | 192 ++++++ .../systemui/haptics/slider/SliderEventType.kt | 4 +- .../systemui/haptics/slider/SliderStateProducer.kt | 62 ++ .../systemui/haptics/slider/SliderStateTracker.kt | 262 ++++++++ .../brightness/BrightnessSliderController.java | 8 +- .../android/systemui/volume/VolumeDialogImpl.java | 6 +- .../slider/SeekableSliderEventProducerTest.kt | 147 ----- .../haptics/slider/SeekableSliderTrackerTest.kt | 729 --------------------- .../haptics/slider/SliderStateTrackerTest.kt | 729 +++++++++++++++++++++ .../brightness/BrightnessSliderControllerTest.kt | 4 +- 17 files changed, 1547 insertions(+), 1546 deletions(-) delete mode 100644 packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekableSliderHapticPluginTest.kt create mode 100644 packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekbarHapticPluginTest.kt create mode 100644 packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SliderStateProducerTest.kt delete mode 100644 packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderEventProducer.kt delete mode 100644 packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderHapticPlugin.kt delete mode 100644 packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt create mode 100644 packages/SystemUI/src/com/android/systemui/haptics/slider/SeekbarHapticPlugin.kt create mode 100644 packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateProducer.kt create mode 100644 packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateTracker.kt delete mode 100644 packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SeekableSliderEventProducerTest.kt delete mode 100644 packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SeekableSliderTrackerTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderStateTrackerTest.kt diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekableSliderHapticPluginTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekableSliderHapticPluginTest.kt deleted file mode 100644 index 805b4a828bda..000000000000 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekableSliderHapticPluginTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (C) 2024 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.haptics.slider - -import android.widget.SeekBar -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import com.android.systemui.SysuiTestCase -import com.android.systemui.kosmos.Kosmos -import com.android.systemui.kosmos.testScope -import com.android.systemui.statusbar.VibratorHelper -import com.android.systemui.util.mockito.whenever -import com.android.systemui.util.time.fakeSystemClock -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.anyInt -import org.mockito.Mock -import org.mockito.junit.MockitoJUnit -import org.mockito.junit.MockitoRule - -@SmallTest -@RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalCoroutinesApi::class) -class SeekableSliderHapticPluginTest : SysuiTestCase() { - - private val kosmos = Kosmos() - - @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule() - @Mock private lateinit var vibratorHelper: VibratorHelper - private val seekBar = SeekBar(mContext) - private lateinit var plugin: SeekableSliderHapticPlugin - - @Before - fun setup() { - whenever(vibratorHelper.getPrimitiveDurations(anyInt())).thenReturn(intArrayOf(0)) - } - - @Test - fun start_beginsTrackingSlider() = runOnStartedPlugin { assertThat(plugin.isTracking).isTrue() } - - @Test - fun stop_stopsTrackingSlider() = runOnStartedPlugin { - // WHEN called to stop - plugin.stop() - - // THEN stops tracking - assertThat(plugin.isTracking).isFalse() - } - - @Test - fun start_afterStop_startsTheTrackingAgain() = runOnStartedPlugin { - // WHEN the plugin is restarted - plugin.stop() - plugin.startInScope(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // THEN the tracking begins again - assertThat(plugin.isTracking).isTrue() - } - - @Test - fun onKeyDown_startsWaiting() = runOnStartedPlugin { - // WHEN a keyDown event is recorded - plugin.onKeyDown() - - // THEN the timer starts waiting - assertThat(plugin.isKeyUpTimerWaiting).isTrue() - } - - @Test - fun keyUpWaitComplete_triggersOnArrowUp() = runOnStartedPlugin { - // GIVEN an onKeyDown that starts the wait and a program progress change that advances the - // slider state to ARROW_HANDLE_MOVED_ONCE - plugin.onKeyDown() - plugin.onProgressChanged(seekBar, 50, false) - testScheduler.runCurrent() - assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE) - - // WHEN the key-up wait completes after the timeout plus a small buffer - advanceTimeBy(KEY_UP_TIMEOUT + 10L) - - // THEN the onArrowUp event is delivered causing the slider tracker to move to IDLE - assertThat(plugin.trackerState).isEqualTo(SliderState.IDLE) - assertThat(plugin.isKeyUpTimerWaiting).isFalse() - } - - @Test - fun onKeyDown_whileWaiting_restartsWait() = runOnStartedPlugin { - // GIVEN an onKeyDown that starts the wait and a program progress change that advances the - // slider state to ARROW_HANDLE_MOVED_ONCE - plugin.onKeyDown() - plugin.onProgressChanged(seekBar, 50, false) - testScheduler.runCurrent() - assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE) - - // WHEN half the timeout period has elapsed and a new keyDown event occurs - advanceTimeBy(KEY_UP_TIMEOUT / 2) - plugin.onKeyDown() - - // AFTER advancing by a period of time that should have complete the original wait - advanceTimeBy(KEY_UP_TIMEOUT / 2 + 10L) - - // THEN the timer is still waiting and the slider tracker remains on ARROW_HANDLE_MOVED_ONCE - assertThat(plugin.isKeyUpTimerWaiting).isTrue() - assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE) - } - - private fun runOnStartedPlugin(test: suspend TestScope.() -> Unit) = - with(kosmos) { - testScope.runTest { - val pluginScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) - createPlugin() - // GIVEN that the plugin is started in a test scope - plugin.startInScope(pluginScope) - - // THEN run the test - test() - } - } - - private fun createPlugin() { - plugin = - SeekableSliderHapticPlugin( - vibratorHelper, - kosmos.fakeSystemClock, - ) - } - - companion object { - private const val KEY_UP_TIMEOUT = 100L - } -} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekbarHapticPluginTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekbarHapticPluginTest.kt new file mode 100644 index 000000000000..855b6d0b95d7 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekbarHapticPluginTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2024 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.haptics.slider + +import android.widget.SeekBar +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testScope +import com.android.systemui.statusbar.VibratorHelper +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.time.fakeSystemClock +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mock +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule + +@SmallTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class SeekbarHapticPluginTest : SysuiTestCase() { + + private val kosmos = Kosmos() + + @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule() + @Mock private lateinit var vibratorHelper: VibratorHelper + private val seekBar = SeekBar(mContext) + private lateinit var plugin: SeekbarHapticPlugin + + @Before + fun setup() { + whenever(vibratorHelper.getPrimitiveDurations(anyInt())).thenReturn(intArrayOf(0)) + } + + @Test + fun start_beginsTrackingSlider() = runOnStartedPlugin { assertThat(plugin.isTracking).isTrue() } + + @Test + fun stop_stopsTrackingSlider() = runOnStartedPlugin { + // WHEN called to stop + plugin.stop() + + // THEN stops tracking + assertThat(plugin.isTracking).isFalse() + } + + @Test + fun start_afterStop_startsTheTrackingAgain() = runOnStartedPlugin { + // WHEN the plugin is restarted + plugin.stop() + plugin.startInScope(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // THEN the tracking begins again + assertThat(plugin.isTracking).isTrue() + } + + @Test + fun onKeyDown_startsWaiting() = runOnStartedPlugin { + // WHEN a keyDown event is recorded + plugin.onKeyDown() + + // THEN the timer starts waiting + assertThat(plugin.isKeyUpTimerWaiting).isTrue() + } + + @Test + fun keyUpWaitComplete_triggersOnArrowUp() = runOnStartedPlugin { + // GIVEN an onKeyDown that starts the wait and a program progress change that advances the + // slider state to ARROW_HANDLE_MOVED_ONCE + plugin.onKeyDown() + plugin.onProgressChanged(seekBar, 50, false) + testScheduler.runCurrent() + assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE) + + // WHEN the key-up wait completes after the timeout plus a small buffer + advanceTimeBy(KEY_UP_TIMEOUT + 10L) + + // THEN the onArrowUp event is delivered causing the slider tracker to move to IDLE + assertThat(plugin.trackerState).isEqualTo(SliderState.IDLE) + assertThat(plugin.isKeyUpTimerWaiting).isFalse() + } + + @Test + fun onKeyDown_whileWaiting_restartsWait() = runOnStartedPlugin { + // GIVEN an onKeyDown that starts the wait and a program progress change that advances the + // slider state to ARROW_HANDLE_MOVED_ONCE + plugin.onKeyDown() + plugin.onProgressChanged(seekBar, 50, false) + testScheduler.runCurrent() + assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE) + + // WHEN half the timeout period has elapsed and a new keyDown event occurs + advanceTimeBy(KEY_UP_TIMEOUT / 2) + plugin.onKeyDown() + + // AFTER advancing by a period of time that should have complete the original wait + advanceTimeBy(KEY_UP_TIMEOUT / 2 + 10L) + + // THEN the timer is still waiting and the slider tracker remains on ARROW_HANDLE_MOVED_ONCE + assertThat(plugin.isKeyUpTimerWaiting).isTrue() + assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE) + } + + private fun runOnStartedPlugin(test: suspend TestScope.() -> Unit) = + with(kosmos) { + testScope.runTest { + val pluginScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + createPlugin() + // GIVEN that the plugin is started in a test scope + plugin.startInScope(pluginScope) + + // THEN run the test + test() + } + } + + private fun createPlugin() { + plugin = + SeekbarHapticPlugin( + vibratorHelper, + kosmos.fakeSystemClock, + ) + } + + companion object { + private const val KEY_UP_TIMEOUT = 100L + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SliderStateProducerTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SliderStateProducerTest.kt new file mode 100644 index 000000000000..88189dbafa6c --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SliderStateProducerTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2024 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.haptics.slider + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class SliderStateProducerTest : SysuiTestCase() { + + private val eventProducer = SliderStateProducer() + private val eventFlow = eventProducer.produceEvents() + + @Test + fun onStartTrackingTouch_noProgress_trackingTouchEventProduced() = runTest { + val latest by collectLastValue(eventFlow) + + eventProducer.onStartTracking(/*fromUser =*/ true) + + assertEquals(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, 0F), latest) + } + + @Test + fun onStopTrackingTouch_noProgress_StoppedTrackingTouchEventProduced() = runTest { + val latest by collectLastValue(eventFlow) + + eventProducer.onStopTracking(/*fromUser =*/ true) + + assertEquals(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0F), latest) + } + + @Test + fun onStartTrackingProgram_noProgress_trackingTouchEventProduced() = runTest { + val latest by collectLastValue(eventFlow) + + eventProducer.onStartTracking(/*fromUser =*/ false) + + assertEquals(SliderEvent(SliderEventType.STARTED_TRACKING_PROGRAM, 0F), latest) + } + + @Test + fun onStopTrackingProgram_noProgress_StoppedTrackingTouchEventProduced() = runTest { + val latest by collectLastValue(eventFlow) + + eventProducer.onStopTracking(/*fromUser =*/ false) + + assertEquals(SliderEvent(SliderEventType.STOPPED_TRACKING_PROGRAM, 0F), latest) + } + + @Test + fun onProgressChangeByUser_changeByUserEventProduced() = runTest { + val progress = 0.5f + val latest by collectLastValue(eventFlow) + + eventProducer.onProgressChanged(/*fromUser =*/ true, progress) + + assertEquals(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress), latest) + } + + @Test + fun onProgressChangeByProgram_changeByProgramEventProduced() = runTest { + val progress = 0.5f + val latest by collectLastValue(eventFlow) + + eventProducer.onProgressChanged(/*fromUser =*/ false, progress) + + assertEquals(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM, progress), latest) + } + + @Test + fun onStartTrackingTouch_afterProgress_trackingTouchEventProduced() = runTest { + val progress = 0.5f + val latest by collectLastValue(eventFlow) + + eventProducer.onProgressChanged(/*fromUser =*/ true, progress) + eventProducer.onStartTracking(/*fromUser =*/ true) + + assertEquals(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress), latest) + } + + @Test + fun onStopTrackingTouch_afterProgress_stopTrackingTouchEventProduced() = runTest { + val progress = 0.5f + val latest by collectLastValue(eventFlow) + + eventProducer.onProgressChanged(/*fromUser =*/ true, progress) + eventProducer.onStopTracking(/*fromUser =*/ true) + + assertEquals(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, progress), latest) + } + + @Test + fun onStartTrackingProgram_afterProgress_trackingProgramEventProduced() = runTest { + val progress = 0.5f + val latest by collectLastValue(eventFlow) + + eventProducer.onProgressChanged(/*fromUser =*/ false, progress) + eventProducer.onStartTracking(/*fromUser =*/ false) + + assertEquals(SliderEvent(SliderEventType.STARTED_TRACKING_PROGRAM, progress), latest) + } + + @Test + fun onStopTrackingProgram_afterProgress_stopTrackingProgramEventProduced() = runTest { + val progress = 0.5f + val latest by collectLastValue(eventFlow) + + eventProducer.onProgressChanged(/*fromUser =*/ false, progress) + eventProducer.onStopTracking(/*fromUser =*/ false) + + assertEquals(SliderEvent(SliderEventType.STOPPED_TRACKING_PROGRAM, progress), latest) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/HapticSliderViewBinder.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/HapticSliderViewBinder.kt index 304fdd61a992..ca6c8da380fe 100644 --- a/packages/SystemUI/src/com/android/systemui/haptics/slider/HapticSliderViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/HapticSliderViewBinder.kt @@ -23,11 +23,11 @@ import kotlinx.coroutines.awaitCancellation object HapticSliderViewBinder { /** - * Binds a [SeekableSliderHapticPlugin] to a [View]. The binded view should be a + * Binds a [SeekbarHapticPlugin] to a [View]. The binded view should be a * [android.widget.SeekBar] or a container of a [android.widget.SeekBar] */ @JvmStatic - fun bind(view: View?, plugin: SeekableSliderHapticPlugin) { + fun bind(view: View?, plugin: SeekbarHapticPlugin) { view?.repeatWhenAttached { plugin.startInScope(lifecycleScope) try { diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderEventProducer.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderEventProducer.kt deleted file mode 100644 index cfa5294567b7..000000000000 --- a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderEventProducer.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (C) 2023 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.haptics.slider - -import android.widget.SeekBar -import android.widget.SeekBar.OnSeekBarChangeListener -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -/** An event producer for a Seekable element such as the Android [SeekBar] */ -class SeekableSliderEventProducer : SliderEventProducer, OnSeekBarChangeListener { - - /** The current event reported by a SeekBar */ - private val _currentEvent = MutableStateFlow(SliderEvent(SliderEventType.NOTHING, 0f)) - - override fun produceEvents(): Flow = _currentEvent.asStateFlow() - - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - val eventType = - if (fromUser) SliderEventType.PROGRESS_CHANGE_BY_USER - else SliderEventType.PROGRESS_CHANGE_BY_PROGRAM - - _currentEvent.value = SliderEvent(eventType, normalizeProgress(seekBar, progress)) - } - - /** - * Normalize the integer progress of a SeekBar to the range from 0F to 1F. - * - * @param[seekBar] The SeekBar that reports a progress. - * @param[progress] The integer progress of the SeekBar within its min and max values. - * @return The progress in the range from 0F to 1F. - */ - private fun normalizeProgress(seekBar: SeekBar, progress: Int): Float { - if (seekBar.max == seekBar.min) { - return 1.0f - } - val range = seekBar.max - seekBar.min - return (progress - seekBar.min) / range.toFloat() - } - - override fun onStartTrackingTouch(seekBar: SeekBar) { - _currentEvent.update { previousEvent -> - SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, previousEvent.currentProgress) - } - } - - override fun onStopTrackingTouch(seekBar: SeekBar) { - _currentEvent.update { previousEvent -> - SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, previousEvent.currentProgress) - } - } - - /** The arrow navigation that was operating the slider has stopped. */ - fun onArrowUp() { - _currentEvent.update { previousEvent -> - SliderEvent(SliderEventType.ARROW_UP, previousEvent.currentProgress) - } - } -} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderHapticPlugin.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderHapticPlugin.kt deleted file mode 100644 index ed82278a7346..000000000000 --- a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderHapticPlugin.kt +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright (C) 2024 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.haptics.slider - -import android.view.MotionEvent -import android.view.VelocityTracker -import android.widget.SeekBar -import androidx.annotation.VisibleForTesting -import com.android.systemui.statusbar.VibratorHelper -import com.android.systemui.util.time.SystemClock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -/** - * A plugin added to a manager of a [android.widget.SeekBar] that adds dynamic haptic feedback. - * - * A [SeekableSliderEventProducer] is used as the producer of slider events, a - * [SliderHapticFeedbackProvider] is used as the listener of slider states to play haptic feedback - * depending on the state, and a [SeekableSliderTracker] is used as the state machine handler that - * tracks and manipulates the slider state. - */ -class SeekableSliderHapticPlugin -@JvmOverloads -constructor( - vibratorHelper: VibratorHelper, - systemClock: SystemClock, - sliderHapticFeedbackConfig: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(), - private val sliderTrackerConfig: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), -) { - - private val velocityTracker = VelocityTracker.obtain() - - private val sliderEventProducer = SeekableSliderEventProducer() - - private val sliderHapticFeedbackProvider = - SliderHapticFeedbackProvider( - vibratorHelper, - velocityTracker, - sliderHapticFeedbackConfig, - systemClock, - ) - - private var sliderTracker: SeekableSliderTracker? = null - - private var pluginScope: CoroutineScope? = null - - val isTracking: Boolean - get() = sliderTracker?.isTracking == true - - val trackerState: SliderState? - get() = sliderTracker?.currentState - - /** - * A waiting [Job] for a timer that estimates the key-up event when a key-down event is - * received. - * - * This is useful for the cases where the slider is being operated by an external key, but the - * release of the key is not easily accessible (e.g., the volume keys) - */ - private var keyUpJob: Job? = null - - @VisibleForTesting - val isKeyUpTimerWaiting: Boolean - get() = keyUpJob != null && keyUpJob?.isActive == true - - /** - * Specify the scope for the plugin's operations and start the slider tracker in this scope. - * This also involves the key-up timer job. - */ - fun startInScope(scope: CoroutineScope) { - if (sliderTracker != null) stop() - sliderTracker = - SeekableSliderTracker( - sliderHapticFeedbackProvider, - sliderEventProducer, - scope, - sliderTrackerConfig, - ) - pluginScope = scope - sliderTracker?.startTracking() - } - - /** - * Stop the plugin - * - * This stops the tracking of slider states, events and triggers of haptic feedback. - */ - fun stop() = sliderTracker?.stopTracking() - - /** React to a touch event */ - fun onTouchEvent(event: MotionEvent?) { - when (event?.actionMasked) { - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> velocityTracker.clear() - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_MOVE -> velocityTracker.addMovement(event) - } - } - - /** onStartTrackingTouch event from the slider's [android.widget.SeekBar] */ - fun onStartTrackingTouch(seekBar: SeekBar) { - if (isTracking) { - sliderEventProducer.onStartTrackingTouch(seekBar) - } - } - - /** onProgressChanged event from the slider's [android.widget.SeekBar] */ - fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (isTracking) { - sliderEventProducer.onProgressChanged(seekBar, progress, fromUser) - } - } - - /** onStopTrackingTouch event from the slider's [android.widget.SeekBar] */ - fun onStopTrackingTouch(seekBar: SeekBar) { - if (isTracking) { - sliderEventProducer.onStopTrackingTouch(seekBar) - } - } - - /** onArrowUp event recorded */ - fun onArrowUp() { - if (isTracking) { - sliderEventProducer.onArrowUp() - } - } - - /** - * An external key was pressed (e.g., a volume key). - * - * This event is used to estimate the key-up event based on a running a timer as a waiting - * coroutine in the [pluginScope]. A key-up event in a slider corresponds to an onArrowUp event. - * Therefore, [onArrowUp] must be called after the timeout. - */ - fun onKeyDown() { - if (!isTracking) return - - if (isKeyUpTimerWaiting) { - // Cancel the ongoing wait - keyUpJob?.cancel() - } - keyUpJob = - pluginScope?.launch { - delay(KEY_UP_TIMEOUT) - onArrowUp() - } - } - - companion object { - const val KEY_UP_TIMEOUT = 60L - } -} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt deleted file mode 100644 index 0af303843a45..000000000000 --- a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (C) 2023 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.haptics.slider - -import androidx.annotation.VisibleForTesting -import kotlin.math.abs -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -/** - * Slider tracker attached to a seekable slider. - * - * The tracker runs a state machine to execute actions on touch-based events typical of a seekable - * slider such as [android.widget.SeekBar]. Coroutines responsible for running the state machine, - * collecting slider events and maintaining waiting states are run on the provided [CoroutineScope]. - * - * @param[sliderStateListener] Listener of the slider state. - * @param[sliderEventProducer] Producer of slider events arising from the slider. - * @param[trackerScope] [CoroutineScope] used to launch coroutines for the collection of slider - * events and the launch of timer jobs. - * @property[config] Configuration parameters of the slider tracker. - */ -class SeekableSliderTracker( - sliderStateListener: SliderStateListener, - sliderEventProducer: SliderEventProducer, - trackerScope: CoroutineScope, - private val config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), -) : SliderTracker(trackerScope, sliderStateListener, sliderEventProducer) { - - // History of the latest progress collected from slider events - private var latestProgress = 0f - // Timer job for the wait state - private var timerJob: Job? = null - // Indicator that there is waiting job active - var isWaiting = false - private set - get() = timerJob != null && timerJob?.isActive == true - - override suspend fun iterateState(event: SliderEvent) { - when (currentState) { - 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) - SliderState.DRAG_HANDLE_REACHED_BOOKEND -> - handleReachedBookend(event.type, event.currentProgress) - 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, 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) - } - } - - private fun launchTimer() = - scope.launch { - delay(config.waitTimeMillis) - if (isActive && currentState == SliderState.WAIT) { - setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) - // This transitory state must also trigger the corresponding action - executeOnState(currentState) - } - } - - private fun handleWait(newEventType: SliderEventType, currentProgress: Float) { - // The timer may have completed and may have already modified the state - if (currentState != SliderState.WAIT) return - - // The timer is still running but the state may be modified by the progress change - val deltaProgressIsJump = deltaProgressIsAboveThreshold(currentProgress) - if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { - if (bookendReached(currentProgress)) { - setState(SliderState.JUMP_BOOKEND_SELECTED) - } else if (deltaProgressIsJump) { - setState(SliderState.JUMP_TRACK_LOCATION_SELECTED) - } else { - setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) - } - } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { - setState(SliderState.IDLE) - } - - // If the state changed, the timer does not need to complete. No further synchronization - // will be required onwards until WAIT is reached again. - if (currentState != SliderState.WAIT) { - timerJob?.cancel() - timerJob = null - } - } - - private fun handleAcquired(newEventType: SliderEventType) { - if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { - setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) - } else if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { - setState(SliderState.DRAG_HANDLE_DRAGGING) - } - } - - private fun handleDragging(newEventType: SliderEventType, currentProgress: Float) { - if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { - setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) - } else if ( - newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER && - bookendReached(currentProgress) - ) { - setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - } - } - - private fun handleReachedBookend(newEventType: SliderEventType, currentProgress: Float) { - if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { - if (!bookendReached(currentProgress)) { - setState(SliderState.DRAG_HANDLE_DRAGGING) - } - } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { - setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) - } - } - - private fun handleJumpToTrack(newEventType: SliderEventType) { - when (newEventType) { - SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING) - SliderEventType.STOPPED_TRACKING_TOUCH -> - setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) - else -> {} - } - } - - private fun handleJumpToBookend(newEventType: SliderEventType) { - when (newEventType) { - SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING) - SliderEventType.STOPPED_TRACKING_TOUCH -> - setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) - else -> {} - } - } - - override fun executeOnState(currentState: SliderState) { - when (currentState) { - SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> sliderListener.onHandleAcquiredByTouch() - SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> { - sliderListener.onHandleReleasedFromTouch() - // This transitory state must also reset the state machine - resetState() - } - SliderState.DRAG_HANDLE_DRAGGING -> sliderListener.onProgress(latestProgress) - 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 -> {} - } - } - - private fun executeOnBookend() { - if (latestProgress >= config.upperBookendThreshold) sliderListener.onUpperBookend() - else sliderListener.onLowerBookend() - } - - override fun resetState() { - timerJob?.cancel() - timerJob = null - super.resetState() - } - - private fun deltaProgressIsAboveThreshold( - currentProgress: Float, - epsilon: Float = 0.00001f, - ): Boolean { - val delta = abs(currentProgress - latestProgress) - return delta > config.jumpThreshold - epsilon - } - - private fun bookendReached(currentProgress: Float): Boolean { - return currentProgress >= config.upperBookendThreshold || - 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 - } -} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekbarHapticPlugin.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekbarHapticPlugin.kt new file mode 100644 index 000000000000..2007db3448e2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekbarHapticPlugin.kt @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2024 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.haptics.slider + +import android.view.MotionEvent +import android.view.VelocityTracker +import android.widget.SeekBar +import androidx.annotation.VisibleForTesting +import com.android.systemui.statusbar.VibratorHelper +import com.android.systemui.util.time.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A plugin added to a manager of a [android.widget.SeekBar] that adds dynamic haptic feedback. + * + * A [SliderStateProducer] is used as the producer of slider events, a + * [SliderHapticFeedbackProvider] is used as the listener of slider states to play haptic feedback + * depending on the state, and a [SliderStateTracker] is used as the state machine handler that + * tracks and manipulates the slider state. + */ +class SeekbarHapticPlugin +@JvmOverloads +constructor( + vibratorHelper: VibratorHelper, + systemClock: SystemClock, + sliderHapticFeedbackConfig: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(), + private val sliderTrackerConfig: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), +) { + + private val velocityTracker = VelocityTracker.obtain() + + private val sliderEventProducer = SliderStateProducer() + + private val sliderHapticFeedbackProvider = + SliderHapticFeedbackProvider( + vibratorHelper, + velocityTracker, + sliderHapticFeedbackConfig, + systemClock, + ) + + private var sliderTracker: SliderStateTracker? = null + + private var pluginScope: CoroutineScope? = null + + val isTracking: Boolean + get() = sliderTracker?.isTracking == true + + val trackerState: SliderState? + get() = sliderTracker?.currentState + + /** + * A waiting [Job] for a timer that estimates the key-up event when a key-down event is + * received. + * + * This is useful for the cases where the slider is being operated by an external key, but the + * release of the key is not easily accessible (e.g., the volume keys) + */ + private var keyUpJob: Job? = null + + @VisibleForTesting + val isKeyUpTimerWaiting: Boolean + get() = keyUpJob != null && keyUpJob?.isActive == true + + /** + * Specify the scope for the plugin's operations and start the slider tracker in this scope. + * This also involves the key-up timer job. + */ + fun startInScope(scope: CoroutineScope) { + if (sliderTracker != null) stop() + sliderTracker = + SliderStateTracker( + sliderHapticFeedbackProvider, + sliderEventProducer, + scope, + sliderTrackerConfig, + ) + pluginScope = scope + sliderTracker?.startTracking() + } + + /** + * Stop the plugin + * + * This stops the tracking of slider states, events and triggers of haptic feedback. + */ + fun stop() = sliderTracker?.stopTracking() + + /** React to a touch event */ + fun onTouchEvent(event: MotionEvent?) { + when (event?.actionMasked) { + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> velocityTracker.clear() + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_MOVE -> velocityTracker.addMovement(event) + } + } + + /** onStartTrackingTouch event from the slider's [android.widget.SeekBar] */ + fun onStartTrackingTouch(seekBar: SeekBar) { + if (isTracking) { + sliderEventProducer.onStartTracking(true) + } + } + + /** onProgressChanged event from the slider's [android.widget.SeekBar] */ + fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (isTracking) { + if (sliderTracker?.currentState == SliderState.IDLE && !fromUser) { + // This case translates to the slider starting to track program changes + sliderEventProducer.resetWithProgress(normalizeProgress(seekBar, progress)) + sliderEventProducer.onStartTracking(false) + } else { + sliderEventProducer.onProgressChanged( + fromUser, + normalizeProgress(seekBar, progress), + ) + } + } + } + + /** + * Normalize the integer progress of a SeekBar to the range from 0F to 1F. + * + * @param[seekBar] The SeekBar that reports a progress. + * @param[progress] The integer progress of the SeekBar within its min and max values. + * @return The progress in the range from 0F to 1F. + */ + private fun normalizeProgress(seekBar: SeekBar, progress: Int): Float { + if (seekBar.max == seekBar.min) { + return 1.0f + } + val range = seekBar.max - seekBar.min + return (progress - seekBar.min) / range.toFloat() + } + + /** onStopTrackingTouch event from the slider's [android.widget.SeekBar] */ + fun onStopTrackingTouch(seekBar: SeekBar) { + if (isTracking) { + sliderEventProducer.onStopTracking(true) + } + } + + /** Programmatic changes have stopped */ + private fun onStoppedTrackingProgram() { + if (isTracking) { + sliderEventProducer.onStopTracking(false) + } + } + + /** + * An external key was pressed (e.g., a volume key). + * + * This event is used to estimate the key-up event based on a running a timer as a waiting + * coroutine in the [pluginScope]. A key-up event in a slider corresponds to an onArrowUp event. + * Therefore, [onStoppedTrackingProgram] must be called after the timeout. + */ + fun onKeyDown() { + if (!isTracking) return + + if (isKeyUpTimerWaiting) { + // Cancel the ongoing wait + keyUpJob?.cancel() + } + keyUpJob = + pluginScope?.launch { + delay(KEY_UP_TIMEOUT) + onStoppedTrackingProgram() + } + } + + companion object { + const val KEY_UP_TIMEOUT = 60L + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderEventType.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderEventType.kt index 4a63941b3f8c..0edef993b782 100644 --- a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderEventType.kt +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderEventType.kt @@ -22,6 +22,8 @@ enum class SliderEventType { NOTHING, /* The slider has captured a touch input and is tracking touch events. */ STARTED_TRACKING_TOUCH, + /* The slider started tracking programmatic value changes */ + STARTED_TRACKING_PROGRAM, /* The slider progress is changing due to user touch input. */ PROGRESS_CHANGE_BY_USER, /* The slider progress is changing programmatically. */ @@ -29,5 +31,5 @@ enum class SliderEventType { /* The slider has stopped tracking touch events. */ STOPPED_TRACKING_TOUCH, /* The external (not touch) stimulus that was modifying the slider progress has stopped. */ - ARROW_UP, + STOPPED_TRACKING_PROGRAM, } diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateProducer.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateProducer.kt new file mode 100644 index 000000000000..1124ab1eb1d4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateProducer.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 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.haptics.slider + +import androidx.annotation.FloatRange +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** A stateful producer of [SliderEvent] */ +class SliderStateProducer : SliderEventProducer { + + /** The current event of a slider */ + private val _currentEvent = MutableStateFlow(SliderEvent(SliderEventType.NOTHING, 0f)) + + override fun produceEvents(): Flow = _currentEvent.asStateFlow() + + fun onProgressChanged(fromUser: Boolean, @FloatRange(from = 0.0, to = 1.0) progress: Float) { + val eventType = + if (fromUser) SliderEventType.PROGRESS_CHANGE_BY_USER + else SliderEventType.PROGRESS_CHANGE_BY_PROGRAM + + _currentEvent.value = SliderEvent(eventType, progress) + } + + fun onStartTracking(fromUser: Boolean) { + val eventType = + if (fromUser) SliderEventType.STARTED_TRACKING_TOUCH + else SliderEventType.STARTED_TRACKING_PROGRAM + _currentEvent.update { previousEvent -> + SliderEvent(eventType, previousEvent.currentProgress) + } + } + + fun onStopTracking(fromUser: Boolean) { + val eventType = + if (fromUser) SliderEventType.STOPPED_TRACKING_TOUCH + else SliderEventType.STOPPED_TRACKING_PROGRAM + _currentEvent.update { previousEvent -> + SliderEvent(eventType, previousEvent.currentProgress) + } + } + + fun resetWithProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) { + _currentEvent.value = SliderEvent(SliderEventType.NOTHING, progress) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateTracker.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateTracker.kt new file mode 100644 index 000000000000..14cf4110272f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateTracker.kt @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2023 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.haptics.slider + +import androidx.annotation.VisibleForTesting +import kotlin.math.abs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Slider tracker attached to a slider. + * + * The tracker runs a state machine to execute actions on touch-based events typical of a general + * slider (including a [android.widget.SeekBar]). Coroutines responsible for running the state + * machine, collecting slider events and maintaining waiting states are run on the provided + * [CoroutineScope]. + * + * @param[sliderStateListener] Listener of the slider state. + * @param[sliderEventProducer] Producer of slider events arising from the slider. + * @param[trackerScope] [CoroutineScope] used to launch coroutines for the collection of slider + * events and the launch of timer jobs. + * @property[config] Configuration parameters of the slider tracker. + */ +class SliderStateTracker( + sliderStateListener: SliderStateListener, + sliderEventProducer: SliderEventProducer, + trackerScope: CoroutineScope, + private val config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), +) : SliderTracker(trackerScope, sliderStateListener, sliderEventProducer) { + + // History of the latest progress collected from slider events + private var latestProgress = 0f + // Timer job for the wait state + private var timerJob: Job? = null + // Indicator that there is waiting job active + var isWaiting = false + private set + get() = timerJob != null && timerJob?.isActive == true + + override suspend fun iterateState(event: SliderEvent) { + when (currentState) { + 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) + SliderState.DRAG_HANDLE_REACHED_BOOKEND -> + handleReachedBookend(event.type, event.currentProgress) + 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, 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.STARTED_TRACKING_PROGRAM) { + val state = + if (bookendReached(currentProgress)) SliderState.ARROW_HANDLE_REACHED_BOOKEND + else SliderState.ARROW_HANDLE_MOVED_ONCE + setState(state) + } + } + + private fun launchTimer() = + scope.launch { + delay(config.waitTimeMillis) + if (isActive && currentState == SliderState.WAIT) { + setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) + // This transitory state must also trigger the corresponding action + executeOnState(currentState) + } + } + + private fun handleWait(newEventType: SliderEventType, currentProgress: Float) { + // The timer may have completed and may have already modified the state + if (currentState != SliderState.WAIT) return + + // The timer is still running but the state may be modified by the progress change + val deltaProgressIsJump = deltaProgressIsAboveThreshold(currentProgress) + if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { + if (bookendReached(currentProgress)) { + setState(SliderState.JUMP_BOOKEND_SELECTED) + } else if (deltaProgressIsJump) { + setState(SliderState.JUMP_TRACK_LOCATION_SELECTED) + } else { + setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) + } + } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { + setState(SliderState.IDLE) + } + + // If the state changed, the timer does not need to complete. No further synchronization + // will be required onwards until WAIT is reached again. + if (currentState != SliderState.WAIT) { + timerJob?.cancel() + timerJob = null + } + } + + private fun handleAcquired(newEventType: SliderEventType) { + if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { + setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) + } else if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { + setState(SliderState.DRAG_HANDLE_DRAGGING) + } + } + + private fun handleDragging(newEventType: SliderEventType, currentProgress: Float) { + if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { + setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) + } else if ( + newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER && + bookendReached(currentProgress) + ) { + setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + } + } + + private fun handleReachedBookend(newEventType: SliderEventType, currentProgress: Float) { + if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) { + if (!bookendReached(currentProgress)) { + setState(SliderState.DRAG_HANDLE_DRAGGING) + } + } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) { + setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) + } + } + + private fun handleJumpToTrack(newEventType: SliderEventType) { + when (newEventType) { + SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING) + SliderEventType.STOPPED_TRACKING_TOUCH -> + setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) + else -> {} + } + } + + private fun handleJumpToBookend(newEventType: SliderEventType) { + when (newEventType) { + SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING) + SliderEventType.STOPPED_TRACKING_TOUCH -> + setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH) + else -> {} + } + } + + override fun executeOnState(currentState: SliderState) { + when (currentState) { + SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> sliderListener.onHandleAcquiredByTouch() + SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> { + sliderListener.onHandleReleasedFromTouch() + // This transitory state must also reset the state machine + resetState() + } + SliderState.DRAG_HANDLE_DRAGGING -> sliderListener.onProgress(latestProgress) + 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 -> {} + } + } + + private fun executeOnBookend() { + if (latestProgress >= config.upperBookendThreshold) sliderListener.onUpperBookend() + else sliderListener.onLowerBookend() + } + + override fun resetState() { + timerJob?.cancel() + timerJob = null + super.resetState() + } + + private fun deltaProgressIsAboveThreshold( + currentProgress: Float, + epsilon: Float = 0.00001f, + ): Boolean { + val delta = abs(currentProgress - latestProgress) + return delta > config.jumpThreshold - epsilon + } + + private fun bookendReached(currentProgress: Float): Boolean { + return currentProgress >= config.upperBookendThreshold || + 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.STOPPED_TRACKING_PROGRAM -> 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.STOPPED_TRACKING_PROGRAM -> 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 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderController.java b/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderController.java index b425fb997d9e..083cee73f591 100644 --- a/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderController.java +++ b/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderController.java @@ -33,7 +33,7 @@ import com.android.settingslib.RestrictedLockUtils; import com.android.systemui.Gefingerpoken; import com.android.systemui.classifier.Classifier; import com.android.systemui.haptics.slider.HapticSliderViewBinder; -import com.android.systemui.haptics.slider.SeekableSliderHapticPlugin; +import com.android.systemui.haptics.slider.SeekbarHapticPlugin; import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.plugins.FalsingManager; import com.android.systemui.res.R; @@ -65,7 +65,7 @@ public class BrightnessSliderController extends ViewController().forEach { - // GIVEN Initialized tracker - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a state in the state machine - mSeekableSliderTracker.setState(it) - - // WHEN the tracker stops tracking the state and listening to events - mSeekableSliderTracker.stopTracking() - - // THEN The state is idle and the tracker is not active - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - assertThat(mSeekableSliderTracker.isTracking).isFalse() - } - } - - // Tests on the IDLE state - @Test - fun initializeSliderTracker_isIdle() = runTest { - // GIVEN Initialized tracker - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // THEN The state is idle and the listener is not called to play haptics - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - verifyZeroInteractions(sliderStateListener) - } - - @Test - fun startsTrackingTouch_onIdle_entersWaitState() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a start of tracking touch event - val progress = 0f - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) - - // THEN the tracker moves to the wait state and the timer job begins - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.WAIT) - verifyZeroInteractions(sliderStateListener) - assertThat(mSeekableSliderTracker.isWaiting).isTrue() - } - - // Tests on the WAIT state - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun waitCompletes_onWait_movesToHandleAcquired() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a start of tracking touch event that moves the tracker to WAIT - val progress = 0f - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) - - // WHEN the wait time completes plus a small buffer time - advanceTimeBy(config.waitTimeMillis + 10L) - - // THEN the tracker moves to the DRAG_HANDLE_ACQUIRED_BY_TOUCH state - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) - assertThat(mSeekableSliderTracker.isWaiting).isFalse() - verify(sliderStateListener).onHandleAcquiredByTouch() - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun impreciseTouch_onWait_movesToHandleAcquired() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the - // slider - var progress = 0.5f - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) - - // GIVEN a progress event due to an imprecise touch with a progress below threshold - progress += (config.jumpThreshold - 0.01f) - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the DRAG_HANDLE_ACQUIRED_BY_TOUCH state without the timer job - // being complete - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) - assertThat(mSeekableSliderTracker.isWaiting).isFalse() - verify(sliderStateListener).onHandleAcquiredByTouch() - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun trackJump_onWait_movesToJumpTrackLocationSelected() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the - // slider - var progress = 0.5f - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) - - // GIVEN a progress event due to a touch on the slider track beyond threshold - progress += (config.jumpThreshold + 0.01f) - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the jump-track location selected state - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.JUMP_TRACK_LOCATION_SELECTED) - assertThat(mSeekableSliderTracker.isWaiting).isFalse() - verify(sliderStateListener).onProgressJump(anyFloat()) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun upperBookendSelection_onWait_movesToBookendSelected() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the - // slider - var progress = 0.5f - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) - - // GIVEN a progress event due to a touch on the slider upper bookend zone. - progress = (config.upperBookendThreshold + 0.01f) - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the jump-track location selected state - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.JUMP_BOOKEND_SELECTED) - assertThat(mSeekableSliderTracker.isWaiting).isFalse() - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun lowerBookendSelection_onWait_movesToBookendSelected() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the - // slider - var progress = 0.5f - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) - - // GIVEN a progress event due to a touch on the slider lower bookend zone - progress = (config.lowerBookendThreshold - 0.01f) - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the JUMP_TRACK_LOCATION_SELECTED state - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.JUMP_BOOKEND_SELECTED) - assertThat(mSeekableSliderTracker.isWaiting).isFalse() - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun stopTracking_onWait_whenWaitingJobIsActive_resetsToIdle() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the - // slider - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, 0.5f)) - assertThat(mSeekableSliderTracker.isWaiting).isTrue() - - // GIVEN that the tracker stops tracking the state and listening to events - mSeekableSliderTracker.stopTracking() - - // THEN the tracker moves to the IDLE state without the timer job being complete - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - assertThat(mSeekableSliderTracker.isWaiting).isFalse() - assertThat(mSeekableSliderTracker.isTracking).isFalse() - verifyNoMoreInteractions(sliderStateListener) - } - - // Tests on the JUMP_TRACK_LOCATION_SELECTED state - - @Test - fun progressChangeByUser_onJumpTrackLocationSelected_movesToDragHandleDragging() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a JUMP_TRACK_LOCATION_SELECTED state - mSeekableSliderTracker.setState(SliderState.JUMP_TRACK_LOCATION_SELECTED) - - // GIVEN a progress event due to dragging the handle - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, 0.5f)) - - // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) - verify(sliderStateListener).onProgress(anyFloat()) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun touchRelease_onJumpTrackLocationSelected_movesToIdle() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a JUMP_TRACK_LOCATION_SELECTED state - mSeekableSliderTracker.setState(SliderState.JUMP_TRACK_LOCATION_SELECTED) - - // GIVEN that the slider stopped tracking touch - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) - - // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state - verify(sliderStateListener).onHandleReleasedFromTouch() - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun progressChangeByUser_onJumpBookendSelected_movesToDragHandleDragging() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a JUMP_BOOKEND_SELECTED state - mSeekableSliderTracker.setState(SliderState.JUMP_BOOKEND_SELECTED) - - // GIVEN that the slider stopped tracking touch - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, 0.5f)) - - // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) - verify(sliderStateListener).onProgress(anyFloat()) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun touchRelease_onJumpBookendSelected_movesToIdle() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a JUMP_BOOKEND_SELECTED state - mSeekableSliderTracker.setState(SliderState.JUMP_BOOKEND_SELECTED) - - // GIVEN that the slider stopped tracking touch - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) - - // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state - verify(sliderStateListener).onHandleReleasedFromTouch() - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - verifyNoMoreInteractions(sliderStateListener) - } - - // Tests on the DRAG_HANDLE_ACQUIRED state - - @Test - fun progressChangeByUser_onHandleAcquired_movesToDragHandleDragging() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a DRAG_HANDLE_ACQUIRED_BY_TOUCH state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) - - // GIVEN a progress change by the user - val progress = 0.5f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state - verify(sliderStateListener).onProgress(progress) - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun touchRelease_onHandleAcquired_movesToIdle() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a DRAG_HANDLE_ACQUIRED_BY_TOUCH state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) - - // GIVEN that the handle stops tracking touch - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) - - // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state - verify(sliderStateListener).onHandleReleasedFromTouch() - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - verifyNoMoreInteractions(sliderStateListener) - } - - // Tests on DRAG_HANDLE_DRAGGING - - @Test - fun progressChangeByUser_onHandleDragging_progressOutsideOfBookends_doesNotChangeState() = - runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a DRAG_HANDLE_DRAGGING state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) - - // GIVEN a progress change by the user outside of bookend bounds - val progress = 0.5f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker does not change state and executes the onProgress call - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) - verify(sliderStateListener).onProgress(progress) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun progressChangeByUser_onHandleDragging_reachesLowerBookend_movesToHandleReachedBookend() = - runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a DRAG_HANDLE_DRAGGING state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) - - // GIVEN a progress change by the user reaching the lower bookend - val progress = config.lowerBookendThreshold - 0.01f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the DRAG_HANDLE_REACHED_BOOKEND state and executes the - // corresponding callback - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - verify(sliderStateListener).onLowerBookend() - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun progressChangeByUser_onHandleDragging_reachesUpperBookend_movesToHandleReachedBookend() = - runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a DRAG_HANDLE_DRAGGING state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) - - // GIVEN a progress change by the user reaching the upper bookend - val progress = config.upperBookendThreshold + 0.01f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the DRAG_HANDLE_REACHED_BOOKEND state and executes the - // corresponding callback - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - verify(sliderStateListener).onUpperBookend() - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun touchRelease_onHandleDragging_movesToIdle() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a DRAG_HANDLE_DRAGGING state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) - - // GIVEN that the slider stops tracking touch - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) - - // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state - verify(sliderStateListener).onHandleReleasedFromTouch() - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - verifyNoMoreInteractions(sliderStateListener) - } - - // Tests on the DRAG_HANDLE_REACHED_BOOKEND state - - @Test - fun progressChangeByUser_outsideOfBookendRange_onLowerBookend_movesToDragHandleDragging() = - runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - - // GIVEN a progress event that falls outside of the lower bookend range - val progress = config.lowerBookendThreshold + 0.01f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state and executes accordingly - verify(sliderStateListener).onProgress(progress) - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun progressChangeByUser_insideOfBookendRange_onLowerBookend_doesNotChangeState() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - - // GIVEN a progress event that falls inside of the lower bookend range - val progress = config.lowerBookendThreshold - 0.01f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker stays in the current state and executes accordingly - verify(sliderStateListener).onLowerBookend() - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun progressChangeByUser_outsideOfBookendRange_onUpperBookend_movesToDragHandleDragging() = - runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - - // GIVEN a progress event that falls outside of the upper bookend range - val progress = config.upperBookendThreshold - 0.01f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state and executes accordingly - verify(sliderStateListener).onProgress(progress) - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun progressChangeByUser_insideOfBookendRange_onUpperBookend_doesNotChangeState() = runTest { - val config = SeekableSliderTrackerConfig() - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) - - // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - - // GIVEN a progress event that falls inside of the upper bookend range - val progress = config.upperBookendThreshold + 0.01f - sliderEventProducer.sendEvent( - SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) - ) - - // THEN the tracker stays in the current state and executes accordingly - verify(sliderStateListener).onUpperBookend() - assertThat(mSeekableSliderTracker.currentState) - .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun touchRelease_onHandleReachedBookend_movesToIdle() = runTest { - initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) - - // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state - mSeekableSliderTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) - - // GIVEN that the handle stops tracking touch - sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) - - // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state - verify(sliderStateListener).onHandleReleasedFromTouch() - assertThat(mSeekableSliderTracker.currentState).isEqualTo(SliderState.IDLE) - verifyNoMoreInteractions(sliderStateListener) - } - - @Test - fun onProgressChangeByProgram_atTheMiddle_onIdle_movesToArrowHandleMovedOnce() = runTest { - // GIVEN an initialized tracker in the IDLE state - initTracker(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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(CoroutineScope(UnconfinedTestDispatcher(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( - scope: CoroutineScope, - config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), - ) { - mSeekableSliderTracker = - SeekableSliderTracker(sliderStateListener, sliderEventProducer, scope, config) - mSeekableSliderTracker.startTracking() - } -} diff --git a/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderStateTrackerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderStateTrackerTest.kt new file mode 100644 index 000000000000..a09d34579e2f --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderStateTrackerTest.kt @@ -0,0 +1,729 @@ +/* + * Copyright (C) 2023 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.haptics.slider + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.anyFloat +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.Mockito.verifyZeroInteractions +import org.mockito.MockitoAnnotations + +@SmallTest +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SliderStateTrackerTest : SysuiTestCase() { + + @Mock private lateinit var sliderStateListener: SliderStateListener + private val sliderEventProducer = FakeSliderEventProducer() + private lateinit var mSliderStateTracker: SliderStateTracker + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + } + + @Test + fun initializeSliderTracker_startsTracking() = runTest { + // GIVEN Initialized tracker + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // THEN the tracker job is active + assertThat(mSliderStateTracker.isTracking).isTrue() + } + + @Test + fun stopTracking_onAnyState_resetsToIdle() = runTest { + enumValues().forEach { + // GIVEN Initialized tracker + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a state in the state machine + mSliderStateTracker.setState(it) + + // WHEN the tracker stops tracking the state and listening to events + mSliderStateTracker.stopTracking() + + // THEN The state is idle and the tracker is not active + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + assertThat(mSliderStateTracker.isTracking).isFalse() + } + } + + // Tests on the IDLE state + @Test + fun initializeSliderTracker_isIdle() = runTest { + // GIVEN Initialized tracker + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // THEN The state is idle and the listener is not called to play haptics + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyZeroInteractions(sliderStateListener) + } + + @Test + fun startsTrackingTouch_onIdle_entersWaitState() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a start of tracking touch event + val progress = 0f + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) + + // THEN the tracker moves to the wait state and the timer job begins + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.WAIT) + verifyZeroInteractions(sliderStateListener) + assertThat(mSliderStateTracker.isWaiting).isTrue() + } + + // Tests on the WAIT state + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun waitCompletes_onWait_movesToHandleAcquired() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a start of tracking touch event that moves the tracker to WAIT + val progress = 0f + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) + + // WHEN the wait time completes plus a small buffer time + advanceTimeBy(config.waitTimeMillis + 10L) + + // THEN the tracker moves to the DRAG_HANDLE_ACQUIRED_BY_TOUCH state + assertThat(mSliderStateTracker.currentState) + .isEqualTo(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) + assertThat(mSliderStateTracker.isWaiting).isFalse() + verify(sliderStateListener).onHandleAcquiredByTouch() + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun impreciseTouch_onWait_movesToHandleAcquired() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the + // slider + var progress = 0.5f + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) + + // GIVEN a progress event due to an imprecise touch with a progress below threshold + progress += (config.jumpThreshold - 0.01f) + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the DRAG_HANDLE_ACQUIRED_BY_TOUCH state without the timer job + // being complete + assertThat(mSliderStateTracker.currentState) + .isEqualTo(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) + assertThat(mSliderStateTracker.isWaiting).isFalse() + verify(sliderStateListener).onHandleAcquiredByTouch() + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun trackJump_onWait_movesToJumpTrackLocationSelected() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the + // slider + var progress = 0.5f + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) + + // GIVEN a progress event due to a touch on the slider track beyond threshold + progress += (config.jumpThreshold + 0.01f) + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the jump-track location selected state + assertThat(mSliderStateTracker.currentState) + .isEqualTo(SliderState.JUMP_TRACK_LOCATION_SELECTED) + assertThat(mSliderStateTracker.isWaiting).isFalse() + verify(sliderStateListener).onProgressJump(anyFloat()) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun upperBookendSelection_onWait_movesToBookendSelected() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the + // slider + var progress = 0.5f + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) + + // GIVEN a progress event due to a touch on the slider upper bookend zone. + progress = (config.upperBookendThreshold + 0.01f) + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the jump-track location selected state + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.JUMP_BOOKEND_SELECTED) + assertThat(mSliderStateTracker.isWaiting).isFalse() + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun lowerBookendSelection_onWait_movesToBookendSelected() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the + // slider + var progress = 0.5f + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, progress)) + + // GIVEN a progress event due to a touch on the slider lower bookend zone + progress = (config.lowerBookendThreshold - 0.01f) + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the JUMP_TRACK_LOCATION_SELECTED state + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.JUMP_BOOKEND_SELECTED) + assertThat(mSliderStateTracker.isWaiting).isFalse() + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun stopTracking_onWait_whenWaitingJobIsActive_resetsToIdle() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a start of tracking touch event that moves the tracker to WAIT at the middle of the + // slider + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STARTED_TRACKING_TOUCH, 0.5f)) + assertThat(mSliderStateTracker.isWaiting).isTrue() + + // GIVEN that the tracker stops tracking the state and listening to events + mSliderStateTracker.stopTracking() + + // THEN the tracker moves to the IDLE state without the timer job being complete + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + assertThat(mSliderStateTracker.isWaiting).isFalse() + assertThat(mSliderStateTracker.isTracking).isFalse() + verifyNoMoreInteractions(sliderStateListener) + } + + // Tests on the JUMP_TRACK_LOCATION_SELECTED state + + @Test + fun progressChangeByUser_onJumpTrackLocationSelected_movesToDragHandleDragging() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a JUMP_TRACK_LOCATION_SELECTED state + mSliderStateTracker.setState(SliderState.JUMP_TRACK_LOCATION_SELECTED) + + // GIVEN a progress event due to dragging the handle + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, 0.5f)) + + // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) + verify(sliderStateListener).onProgress(anyFloat()) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun touchRelease_onJumpTrackLocationSelected_movesToIdle() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a JUMP_TRACK_LOCATION_SELECTED state + mSliderStateTracker.setState(SliderState.JUMP_TRACK_LOCATION_SELECTED) + + // GIVEN that the slider stopped tracking touch + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) + + // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state + verify(sliderStateListener).onHandleReleasedFromTouch() + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun progressChangeByUser_onJumpBookendSelected_movesToDragHandleDragging() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a JUMP_BOOKEND_SELECTED state + mSliderStateTracker.setState(SliderState.JUMP_BOOKEND_SELECTED) + + // GIVEN that the slider stopped tracking touch + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, 0.5f)) + + // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) + verify(sliderStateListener).onProgress(anyFloat()) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun touchRelease_onJumpBookendSelected_movesToIdle() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a JUMP_BOOKEND_SELECTED state + mSliderStateTracker.setState(SliderState.JUMP_BOOKEND_SELECTED) + + // GIVEN that the slider stopped tracking touch + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) + + // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state + verify(sliderStateListener).onHandleReleasedFromTouch() + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyNoMoreInteractions(sliderStateListener) + } + + // Tests on the DRAG_HANDLE_ACQUIRED state + + @Test + fun progressChangeByUser_onHandleAcquired_movesToDragHandleDragging() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a DRAG_HANDLE_ACQUIRED_BY_TOUCH state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) + + // GIVEN a progress change by the user + val progress = 0.5f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state + verify(sliderStateListener).onProgress(progress) + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun touchRelease_onHandleAcquired_movesToIdle() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a DRAG_HANDLE_ACQUIRED_BY_TOUCH state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH) + + // GIVEN that the handle stops tracking touch + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) + + // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state + verify(sliderStateListener).onHandleReleasedFromTouch() + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyNoMoreInteractions(sliderStateListener) + } + + // Tests on DRAG_HANDLE_DRAGGING + + @Test + fun progressChangeByUser_onHandleDragging_progressOutsideOfBookends_doesNotChangeState() = + runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a DRAG_HANDLE_DRAGGING state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) + + // GIVEN a progress change by the user outside of bookend bounds + val progress = 0.5f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker does not change state and executes the onProgress call + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) + verify(sliderStateListener).onProgress(progress) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun progressChangeByUser_onHandleDragging_reachesLowerBookend_movesToHandleReachedBookend() = + runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a DRAG_HANDLE_DRAGGING state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) + + // GIVEN a progress change by the user reaching the lower bookend + val progress = config.lowerBookendThreshold - 0.01f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the DRAG_HANDLE_REACHED_BOOKEND state and executes the + // corresponding callback + assertThat(mSliderStateTracker.currentState) + .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + verify(sliderStateListener).onLowerBookend() + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun progressChangeByUser_onHandleDragging_reachesUpperBookend_movesToHandleReachedBookend() = + runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a DRAG_HANDLE_DRAGGING state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) + + // GIVEN a progress change by the user reaching the upper bookend + val progress = config.upperBookendThreshold + 0.01f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the DRAG_HANDLE_REACHED_BOOKEND state and executes the + // corresponding callback + assertThat(mSliderStateTracker.currentState) + .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + verify(sliderStateListener).onUpperBookend() + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun touchRelease_onHandleDragging_movesToIdle() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a DRAG_HANDLE_DRAGGING state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_DRAGGING) + + // GIVEN that the slider stops tracking touch + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) + + // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state + verify(sliderStateListener).onHandleReleasedFromTouch() + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyNoMoreInteractions(sliderStateListener) + } + + // Tests on the DRAG_HANDLE_REACHED_BOOKEND state + + @Test + fun progressChangeByUser_outsideOfBookendRange_onLowerBookend_movesToDragHandleDragging() = + runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + + // GIVEN a progress event that falls outside of the lower bookend range + val progress = config.lowerBookendThreshold + 0.01f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state and executes accordingly + verify(sliderStateListener).onProgress(progress) + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun progressChangeByUser_insideOfBookendRange_onLowerBookend_doesNotChangeState() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + + // GIVEN a progress event that falls inside of the lower bookend range + val progress = config.lowerBookendThreshold - 0.01f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker stays in the current state and executes accordingly + verify(sliderStateListener).onLowerBookend() + assertThat(mSliderStateTracker.currentState) + .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun progressChangeByUser_outsideOfBookendRange_onUpperBookend_movesToDragHandleDragging() = + runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + + // GIVEN a progress event that falls outside of the upper bookend range + val progress = config.upperBookendThreshold - 0.01f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker moves to the DRAG_HANDLE_DRAGGING state and executes accordingly + verify(sliderStateListener).onProgress(progress) + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.DRAG_HANDLE_DRAGGING) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun progressChangeByUser_insideOfBookendRange_onUpperBookend_doesNotChangeState() = runTest { + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + + // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + + // GIVEN a progress event that falls inside of the upper bookend range + val progress = config.upperBookendThreshold + 0.01f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.PROGRESS_CHANGE_BY_USER, progress) + ) + + // THEN the tracker stays in the current state and executes accordingly + verify(sliderStateListener).onUpperBookend() + assertThat(mSliderStateTracker.currentState) + .isEqualTo(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun touchRelease_onHandleReachedBookend_movesToIdle() = runTest { + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + // GIVEN a DRAG_HANDLE_REACHED_BOOKEND state + mSliderStateTracker.setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND) + + // GIVEN that the handle stops tracking touch + sliderEventProducer.sendEvent(SliderEvent(SliderEventType.STOPPED_TRACKING_TOUCH, 0.5f)) + + // THEN the tracker executes on onHandleReleasedFromTouch before moving to the IDLE state + verify(sliderStateListener).onHandleReleasedFromTouch() + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyNoMoreInteractions(sliderStateListener) + } + + @Test + fun onStartedTrackingProgram_atTheMiddle_onIdle_movesToArrowHandleMovedOnce() = runTest { + // GIVEN an initialized tracker in the IDLE state + initTracker(CoroutineScope(UnconfinedTestDispatcher(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.STARTED_TRACKING_PROGRAM, progress) + ) + + // THEN the state moves to ARROW_HANDLE_MOVED_ONCE and the listener is called to play + // haptics + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE) + verify(sliderStateListener).onSelectAndArrow(progress) + } + + @Test + fun onStartedTrackingProgram_atUpperBookend_onIdle_movesToIdle() = runTest { + // GIVEN an initialized tracker in the IDLE state + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(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.STARTED_TRACKING_PROGRAM, progress) + ) + + // THEN the tracker executes upper bookend haptics before moving back to IDLE + verify(sliderStateListener).onUpperBookend() + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + } + + @Test + fun onStartedTrackingProgram_atLowerBookend_onIdle_movesToIdle() = runTest { + // GIVEN an initialized tracker in the IDLE state + val config = SeekableSliderTrackerConfig() + initTracker(CoroutineScope(UnconfinedTestDispatcher(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.STARTED_TRACKING_PROGRAM, progress) + ) + + // THEN the tracker executes lower bookend haptics before moving to IDLE + verify(sliderStateListener).onLowerBookend() + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + } + + @Test + fun onArrowUp_onArrowMovedOnce_movesToIdle() = runTest { + // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVED_ONCE) + + // WHEN the external stimulus is released + val progress = 0.5f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.STOPPED_TRACKING_PROGRAM, progress) + ) + + // THEN the tracker moves back to IDLE and there are no haptics + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyZeroInteractions(sliderStateListener) + } + + @Test + fun onStartTrackingTouch_onArrowMovedOnce_movesToWait() = runTest { + // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + mSliderStateTracker.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(mSliderStateTracker.currentState).isEqualTo(SliderState.WAIT) + assertThat(mSliderStateTracker.isWaiting).isTrue() + verifyZeroInteractions(sliderStateListener) + } + + @Test + fun onProgressChangeByProgram_onArrowMovedOnce_movesToArrowMovesContinuously() = runTest { + // GIVEN an initialized tracker in the ARROW_HANDLE_MOVED_ONCE state + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + mSliderStateTracker.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(mSliderStateTracker.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(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + mSliderStateTracker.setState(SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY) + + // WHEN the external stimulus is released + val progress = 0.5f + sliderEventProducer.sendEvent( + SliderEvent(SliderEventType.STOPPED_TRACKING_PROGRAM, progress) + ) + + // THEN the tracker moves to IDLE and no haptics are played + assertThat(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + verifyZeroInteractions(sliderStateListener) + } + + @Test + fun onStartTrackingTouch_onArrowMovesContinuously_movesToWait() = runTest { + // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + mSliderStateTracker.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(mSliderStateTracker.currentState).isEqualTo(SliderState.WAIT) + assertThat(mSliderStateTracker.isWaiting).isTrue() + verifyZeroInteractions(sliderStateListener) + } + + @Test + fun onProgressChangeByProgram_onArrowMovesContinuously_preservesState() = runTest { + // GIVEN an initialized tracker in the ARROW_HANDLE_MOVES_CONTINUOUSLY state + initTracker(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + mSliderStateTracker.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(mSliderStateTracker.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(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + mSliderStateTracker.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(mSliderStateTracker.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(CoroutineScope(UnconfinedTestDispatcher(testScheduler)), config) + mSliderStateTracker.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(mSliderStateTracker.currentState).isEqualTo(SliderState.IDLE) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun initTracker( + scope: CoroutineScope, + config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), + ) { + mSliderStateTracker = + SliderStateTracker(sliderStateListener, sliderEventProducer, scope, config) + mSliderStateTracker.startTracking() + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderControllerTest.kt index 6a22d8648d91..fb91c78b9041 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderControllerTest.kt @@ -24,7 +24,7 @@ import com.android.internal.logging.testing.UiEventLoggerFake import com.android.settingslib.RestrictedLockUtils import com.android.systemui.SysuiTestCase import com.android.systemui.classifier.FalsingManagerFake -import com.android.systemui.haptics.slider.SeekableSliderHapticPlugin +import com.android.systemui.haptics.slider.SeekbarHapticPlugin import com.android.systemui.plugins.ActivityStarter import com.android.systemui.statusbar.VibratorHelper import com.android.systemui.statusbar.policy.BrightnessMirrorController @@ -93,7 +93,7 @@ class BrightnessSliderControllerTest : SysuiTestCase() { brightnessSliderView, mFalsingManager, uiEventLogger, - SeekableSliderHapticPlugin(vibratorHelper, systemClock), + SeekbarHapticPlugin(vibratorHelper, systemClock), activityStarter, ) mController.init() -- cgit v1.2.3-59-g8ed1b