summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt212
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTrackerConfig.kt37
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackConfig.kt43
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProvider.kt160
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/slider/SliderState.kt37
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateListener.kt64
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/slider/SliderTracker.kt93
-rw-r--r--packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderController.java68
-rw-r--r--packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPlugin.kt46
-rw-r--r--packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImpl.kt69
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java21
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/haptics/slider/FakeSliderEventProducer.kt32
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SeekableSliderTrackerTest.kt547
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt247
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderControllerTest.kt12
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImplTest.kt102
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt12
17 files changed, 1797 insertions, 5 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt
new file mode 100644
index 000000000000..cc51d21744e8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTracker.kt
@@ -0,0 +1,212 @@
+/*
+ * 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 com.android.systemui.dagger.qualifiers.Main
+import kotlin.math.abs
+import kotlinx.coroutines.CoroutineDispatcher
+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 main thread via the
+ * [com.android.systemui.dagger.qualifiers.Main] coroutine dispatcher.
+ *
+ * @param[sliderStateListener] Listener of the slider state.
+ * @param[sliderEventProducer] Producer of slider events arising from the slider.
+ * @property[scope] [CoroutineScope] where the collection of slider events and the launch of timer
+ * jobs occur.
+ * @property[config] Configuration parameters of the slider tracker.
+ */
+class SeekableSliderTracker(
+ sliderStateListener: SliderStateListener,
+ sliderEventProducer: SliderEventProducer,
+ @Main mainDispatcher: CoroutineDispatcher,
+ private val config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
+) : SliderTracker(CoroutineScope(mainDispatcher), 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)
+ 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)
+ }
+ latestProgress = event.currentProgress
+ }
+
+ private fun handleIdle(newEventType: SliderEventType) {
+ 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)
+ }
+ }
+
+ 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.JUMP_BOOKEND_SELECTED -> executeOnBookend()
+ 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 abs(delta - config.jumpThreshold) < epsilon
+ }
+
+ private fun bookendReached(currentProgress: Float): Boolean {
+ return currentProgress >= config.upperBookendThreshold ||
+ currentProgress <= config.lowerBookendThreshold
+ }
+
+ @VisibleForTesting
+ fun setState(state: SliderState) {
+ currentState = state
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTrackerConfig.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTrackerConfig.kt
new file mode 100644
index 000000000000..cb0f43b8235a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderTrackerConfig.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.FloatRange
+
+/**
+ * Configuration parameters of a seekable slider tracker.
+ *
+ * @property[waitTimeMillis] Wait period to determine if a touch event acquires the slider handle.
+ * @property[jumpThreshold] Threshold on the slider progress to detect if a touch event is qualified
+ * as an imprecise acquisition of the slider handle.
+ * @property[lowerBookendThreshold] Threshold to determine the progress on the slider that qualifies
+ * as reaching the lower bookend.
+ * @property[upperBookendThreshold] Threshold to determine the progress on the slider that qualifies
+ * as reaching the upper bookend.
+ */
+data class SeekableSliderTrackerConfig(
+ val waitTimeMillis: Long = 100,
+ @FloatRange(from = 0.0, to = 1.0) val jumpThreshold: Float = 0.02f,
+ @FloatRange(from = 0.0, to = 1.0) val lowerBookendThreshold: Float = 0.05f,
+ @FloatRange(from = 0.0, to = 1.0) val upperBookendThreshold: Float = 0.95f,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackConfig.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackConfig.kt
new file mode 100644
index 000000000000..20d99d1e75fb
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackConfig.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.FloatRange
+
+/** Configuration parameters of a [SliderHapticFeedbackProvider] */
+data class SliderHapticFeedbackConfig(
+ /** Interpolator factor for velocity-based vibration scale interpolations. Must be positive */
+ val velocityInterpolatorFactor: Float = 1f,
+ /** Interpolator factor for progress-based vibration scale interpolations. Must be positive */
+ val progressInterpolatorFactor: Float = 1f,
+ /** Minimum vibration scale for vibrations based on slider progress */
+ @FloatRange(from = 0.0, to = 1.0) val progressBasedDragMinScale: Float = 0f,
+ /** Maximum vibration scale for vibrations based on slider progress */
+ @FloatRange(from = 0.0, to = 1.0) val progressBasedDragMaxScale: Float = 0.2f,
+ /** Additional vibration scaling due to velocity */
+ @FloatRange(from = 0.0, to = 1.0) val additionalVelocityMaxBump: Float = 0.15f,
+ /** Additional time delta to wait between drag texture vibrations */
+ @FloatRange(from = 0.0) val deltaMillisForDragInterval: Float = 0f,
+ /** Number of low ticks in a drag texture composition. This is not expected to change */
+ val numberOfLowTicks: Int = 5,
+ /** Maximum velocity allowed for vibration scaling. This is not expected to change. */
+ val maxVelocityToScale: Float = 2000f, /* In pixels/sec */
+ /** Vibration scale at the upper bookend of the slider */
+ @FloatRange(from = 0.0, to = 1.0) val upperBookendScale: Float = 1f,
+ /** Vibration scale at the lower bookend of the slider */
+ @FloatRange(from = 0.0, to = 1.0) val lowerBookendScale: Float = 0.05f,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProvider.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProvider.kt
new file mode 100644
index 000000000000..e6de156de0c4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProvider.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.os.VibrationAttributes
+import android.os.VibrationEffect
+import android.view.VelocityTracker
+import android.view.animation.AccelerateInterpolator
+import androidx.annotation.FloatRange
+import com.android.systemui.statusbar.VibratorHelper
+import kotlin.math.abs
+import kotlin.math.min
+
+/**
+ * Listener of slider events that triggers haptic feedback.
+ *
+ * @property[vibratorHelper] Singleton instance of the [VibratorHelper] to deliver haptics.
+ * @property[velocityTracker] Instance of a [VelocityTracker] that tracks slider dragging velocity.
+ * @property[config] Configuration parameters for vibration encapsulated as a
+ * [SliderHapticFeedbackConfig].
+ * @property[clock] Clock to obtain elapsed real time values.
+ */
+class SliderHapticFeedbackProvider(
+ private val vibratorHelper: VibratorHelper,
+ private val velocityTracker: VelocityTracker,
+ private val config: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(),
+ private val clock: com.android.systemui.util.time.SystemClock,
+) : SliderStateListener {
+
+ private val velocityAccelerateInterpolator =
+ AccelerateInterpolator(config.velocityInterpolatorFactor)
+ private val positionAccelerateInterpolator =
+ AccelerateInterpolator(config.progressInterpolatorFactor)
+ private var dragTextureLastTime = clock.elapsedRealtime()
+ private val lowTickDurationMs =
+ vibratorHelper.getPrimitiveDurations(VibrationEffect.Composition.PRIMITIVE_LOW_TICK)[0]
+ private var hasVibratedAtLowerBookend = false
+ private var hasVibratedAtUpperBookend = false
+
+ /** Time threshold to wait before making new API call. */
+ private val thresholdUntilNextDragCallMillis =
+ lowTickDurationMs * config.numberOfLowTicks + config.deltaMillisForDragInterval
+
+ /**
+ * Vibrate when the handle reaches either bookend with a certain velocity.
+ *
+ * @param[absoluteVelocity] Velocity of the handle when it reached the bookend.
+ */
+ private fun vibrateOnEdgeCollision(absoluteVelocity: Float) {
+ val velocityInterpolated =
+ velocityAccelerateInterpolator.getInterpolation(
+ min(absoluteVelocity / config.maxVelocityToScale, 1f)
+ )
+ val bookendScaleRange = config.upperBookendScale - config.lowerBookendScale
+ val bookendsHitScale = bookendScaleRange * velocityInterpolated + config.lowerBookendScale
+
+ val vibration =
+ VibrationEffect.startComposition()
+ .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, bookendsHitScale)
+ .compose()
+ vibratorHelper.vibrate(vibration, VIBRATION_ATTRIBUTES_PIPELINING)
+ }
+
+ /**
+ * Create a drag texture vibration based on velocity and slider progress.
+ *
+ * @param[absoluteVelocity] Absolute velocity of the handle.
+ * @param[normalizedSliderProgress] Progress of the slider handled normalized to the range from
+ * 0F to 1F (inclusive).
+ */
+ private fun vibrateDragTexture(
+ absoluteVelocity: Float,
+ @FloatRange(from = 0.0, to = 1.0) normalizedSliderProgress: Float
+ ) {
+ // Check if its time to vibrate
+ val currentTime = clock.elapsedRealtime()
+ val elapsedSinceLastDrag = currentTime - dragTextureLastTime
+ if (elapsedSinceLastDrag < thresholdUntilNextDragCallMillis) return
+
+ val velocityInterpolated =
+ velocityAccelerateInterpolator.getInterpolation(
+ min(absoluteVelocity / config.maxVelocityToScale, 1f)
+ )
+
+ // Scaling of vibration due to the position of the slider
+ val positionScaleRange = config.progressBasedDragMaxScale - config.progressBasedDragMinScale
+ val sliderProgressInterpolated =
+ positionAccelerateInterpolator.getInterpolation(normalizedSliderProgress)
+ val positionBasedScale =
+ positionScaleRange * sliderProgressInterpolated + config.progressBasedDragMinScale
+
+ // Scaling bump due to velocity
+ val velocityBasedScale = velocityInterpolated * config.additionalVelocityMaxBump
+
+ // Total scale
+ val scale = positionBasedScale + velocityBasedScale
+
+ // Trigger the vibration composition
+ val composition = VibrationEffect.startComposition()
+ repeat(config.numberOfLowTicks) {
+ composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, scale)
+ }
+ vibratorHelper.vibrate(composition.compose(), VIBRATION_ATTRIBUTES_PIPELINING)
+ dragTextureLastTime = currentTime
+ }
+
+ override fun onHandleAcquiredByTouch() {}
+
+ override fun onHandleReleasedFromTouch() {}
+
+ override fun onLowerBookend() {
+ if (!hasVibratedAtLowerBookend) {
+ velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
+ vibrateOnEdgeCollision(abs(velocityTracker.xVelocity))
+ hasVibratedAtLowerBookend = true
+ }
+ }
+
+ override fun onUpperBookend() {
+ if (!hasVibratedAtUpperBookend) {
+ velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
+ vibrateOnEdgeCollision(abs(velocityTracker.xVelocity))
+ hasVibratedAtUpperBookend = true
+ }
+ }
+
+ override fun onProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) {
+ velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
+ vibrateDragTexture(abs(velocityTracker.xVelocity), progress)
+ hasVibratedAtUpperBookend = false
+ hasVibratedAtLowerBookend = false
+ }
+
+ override fun onProgressJump(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}
+
+ override fun onSelectAndArrow(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}
+
+ private companion object {
+ private val VIBRATION_ATTRIBUTES_PIPELINING =
+ VibrationAttributes.Builder()
+ .setUsage(VibrationAttributes.USAGE_TOUCH)
+ .setFlags(VibrationAttributes.FLAG_PIPELINED_EFFECT)
+ .build()
+ private const val UNITS_SECOND = 1000
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderState.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderState.kt
new file mode 100644
index 000000000000..fe092e67036b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderState.kt
@@ -0,0 +1,37 @@
+/*
+ * 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
+
+/** State of a slider */
+enum class SliderState {
+ /* The slider is idle */
+ IDLE,
+ /* Waiting state to disambiguate between handle acquisition and select and jump operations */
+ WAIT,
+ /* The slider handle was acquired by touch. */
+ DRAG_HANDLE_ACQUIRED_BY_TOUCH,
+ /* The slider handle was released. */
+ DRAG_HANDLE_RELEASED_FROM_TOUCH,
+ /* The slider handle is being dragged by touch. */
+ DRAG_HANDLE_DRAGGING,
+ /* The slider handle reached a bookend. */
+ DRAG_HANDLE_REACHED_BOOKEND,
+ /* A location in the slider track has been selected. */
+ JUMP_TRACK_LOCATION_SELECTED,
+ /* The slider handled moved to a bookend after it was selected. */
+ JUMP_BOOKEND_SELECTED,
+}
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateListener.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateListener.kt
new file mode 100644
index 000000000000..9c99c90bb910
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateListener.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.FloatRange
+
+/** Listener of events from a slider (such as [android.widget.SeekBar]) */
+interface SliderStateListener {
+
+ /** Notification that the handle is acquired by touch */
+ fun onHandleAcquiredByTouch()
+
+ /** Notification that the handle was released from touch */
+ fun onHandleReleasedFromTouch()
+
+ /** Notification that the handle reached the lower bookend */
+ fun onLowerBookend()
+
+ /** Notification that the handle reached the upper bookend */
+ fun onUpperBookend()
+
+ /**
+ * Notification that the slider reached a certain progress on the slider track.
+ *
+ * This method is called in all intermediate steps of a continuous progress change as the slider
+ * moves through the slider track.
+ *
+ * @param[progress] The progress of the slider in the range from 0F to 1F (inclusive).
+ */
+ fun onProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float)
+
+ /**
+ * Notification that the slider handle jumped to a selected progress on the slider track.
+ *
+ * This method is specific to the case when the handle performed a single jump to a position on
+ * the slider track and reached the corresponding progress. In this case, [onProgress] is not
+ * called and the new progress reached is represented by the [progress] parameter.
+ *
+ * @param[progress] The selected progress on the slider track that the handle jumps to. The
+ * progress is in the range from 0F to 1F (inclusive).
+ */
+ fun onProgressJump(@FloatRange(from = 0.0, to = 1.0) progress: Float)
+
+ /**
+ * Notification that the slider handle was moved by a button press.
+ *
+ * @param[progress] The progress of the slider in the range from 0F to 1F (inclusive).
+ */
+ fun onSelectAndArrow(@FloatRange(from = 0.0, to = 1.0) progress: Float)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderTracker.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderTracker.kt
new file mode 100644
index 000000000000..e1f57089d792
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderTracker.kt
@@ -0,0 +1,93 @@
+/*
+ * 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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+/**
+ * Tracker component for a slider.
+ *
+ * The tracker maintains a state machine operated by slider events coming from a
+ * [SliderEventProducer]. An action is executed in each state via a [SliderListener].
+ *
+ * @param[scope] [CoroutineScope] to launch the collection of [SliderEvent].
+ * @property[sliderListener] [SliderListener] to execute actions on a given [SliderState].
+ * @property[eventProducer] Producer of [SliderEvent] to iterate over a state machine.
+ */
+sealed class SliderTracker(
+ protected val scope: CoroutineScope,
+ protected val sliderListener: SliderStateListener,
+ protected val eventProducer: SliderEventProducer,
+) {
+
+ /* Reference to the current state of the internal state machine */
+ var currentState: SliderState = SliderState.IDLE
+ protected set
+
+ /**
+ * Job that launches and maintains the coroutine that collects events and operates the state
+ * machine.
+ */
+ protected var job: Job? = null
+
+ /** Indicator that the tracker is active and tracking */
+ var isTracking = false
+ get() = job != null && job?.isActive == true
+ private set
+
+ /** Starts the [Job] that collects slider events and runs the state machine */
+ fun startTracking() {
+ job =
+ scope.launch {
+ eventProducer.produceEvents().collect { event ->
+ iterateState(event)
+ executeOnState(currentState)
+ }
+ }
+ }
+
+ /** Stops the collection of slider events and the state machine */
+ fun stopTracking() {
+ job?.cancel("Stopped tracking slider state")
+ job = null
+ resetState()
+ }
+
+ /**
+ * Iterate through the state machine due to a new slider event. As a result, the current state
+ * is modified.
+ *
+ * @param[event] The slider event that is received.
+ */
+ protected abstract suspend fun iterateState(event: SliderEvent)
+
+ /**
+ * Execute an action based on the state of the state machine. This method should use the
+ * [SliderListener] to act on the current state.
+ *
+ * @param[currentState] A [SliderState] in the state machine
+ */
+ protected abstract fun executeOnState(currentState: SliderState)
+
+ /** Reset the state machine by setting the current state to [SliderState.IDLE] */
+ protected open fun resetState() {
+ currentState = SliderState.IDLE
+ }
+}
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 7415b7882c70..b7152370624e 100644
--- a/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderController.java
+++ b/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderController.java
@@ -16,6 +16,8 @@
package com.android.systemui.settings.brightness;
+import static com.android.systemui.flags.Flags.HAPTIC_BRIGHTNESS_SLIDER;
+
import android.content.Context;
import android.view.LayoutInflater;
import android.view.MotionEvent;
@@ -30,12 +32,19 @@ import com.android.settingslib.RestrictedLockUtils;
import com.android.systemui.Gefingerpoken;
import com.android.systemui.res.R;
import com.android.systemui.classifier.Classifier;
+import com.android.systemui.dagger.qualifiers.Main;
+import com.android.systemui.flags.FeatureFlagsClassic;
+import com.android.systemui.haptics.slider.SeekableSliderEventProducer;
import com.android.systemui.plugins.FalsingManager;
+import com.android.systemui.statusbar.VibratorHelper;
import com.android.systemui.statusbar.policy.BrightnessMirrorController;
import com.android.systemui.util.ViewController;
+import com.android.systemui.util.time.SystemClock;
import javax.inject.Inject;
+import kotlinx.coroutines.CoroutineDispatcher;
+
/**
* {@code ViewController} for a {@code BrightnessSliderView}
*
@@ -55,12 +64,21 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
private final FalsingManager mFalsingManager;
private final UiEventLogger mUiEventLogger;
+ private final BrightnessSliderHapticPlugin mBrightnessSliderHapticPlugin;
+
private final Gefingerpoken mOnInterceptListener = new Gefingerpoken() {
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
mFalsingManager.isFalseTouch(Classifier.BRIGHTNESS_SLIDER);
+ if (mBrightnessSliderHapticPlugin.getVelocityTracker() != null) {
+ mBrightnessSliderHapticPlugin.getVelocityTracker().clear();
+ }
+ } else if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) {
+ if (mBrightnessSliderHapticPlugin.getVelocityTracker() != null) {
+ mBrightnessSliderHapticPlugin.getVelocityTracker().addMovement(ev);
+ }
}
return false;
@@ -75,10 +93,12 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
BrightnessSliderController(
BrightnessSliderView brightnessSliderView,
FalsingManager falsingManager,
- UiEventLogger uiEventLogger) {
+ UiEventLogger uiEventLogger,
+ BrightnessSliderHapticPlugin brightnessSliderHapticPlugin) {
super(brightnessSliderView);
mFalsingManager = falsingManager;
mUiEventLogger = uiEventLogger;
+ mBrightnessSliderHapticPlugin = brightnessSliderHapticPlugin;
}
/**
@@ -93,6 +113,7 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
protected void onViewAttached() {
mView.setOnSeekBarChangeListener(mSeekListener);
mView.setOnInterceptListener(mOnInterceptListener);
+ mBrightnessSliderHapticPlugin.start();
}
@Override
@@ -100,6 +121,7 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
mView.setOnSeekBarChangeListener(null);
mView.setOnDispatchTouchEventListener(null);
mView.setOnInterceptListener(null);
+ mBrightnessSliderHapticPlugin.stop();
}
@Override
@@ -204,6 +226,11 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (mListener != null) {
mListener.onChanged(mTracking, progress, false);
+ SeekableSliderEventProducer eventProducer =
+ mBrightnessSliderHapticPlugin.getSeekableSliderEventProducer();
+ if (eventProducer != null) {
+ eventProducer.onProgressChanged(seekBar, progress, fromUser);
+ }
}
}
@@ -213,6 +240,11 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
mUiEventLogger.log(BrightnessSliderEvent.SLIDER_STARTED_TRACKING_TOUCH);
if (mListener != null) {
mListener.onChanged(mTracking, getValue(), false);
+ SeekableSliderEventProducer eventProducer =
+ mBrightnessSliderHapticPlugin.getSeekableSliderEventProducer();
+ if (eventProducer != null) {
+ eventProducer.onStartTrackingTouch(seekBar);
+ }
}
if (mMirrorController != null) {
@@ -227,6 +259,11 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
mUiEventLogger.log(BrightnessSliderEvent.SLIDER_STOPPED_TRACKING_TOUCH);
if (mListener != null) {
mListener.onChanged(mTracking, getValue(), true);
+ SeekableSliderEventProducer eventProducer =
+ mBrightnessSliderHapticPlugin.getSeekableSliderEventProducer();
+ if (eventProducer != null) {
+ eventProducer.onStopTrackingTouch(seekBar);
+ }
}
if (mMirrorController != null) {
@@ -242,11 +279,26 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
private final FalsingManager mFalsingManager;
private final UiEventLogger mUiEventLogger;
+ private final FeatureFlagsClassic mFeatureFlags;
+ private final VibratorHelper mVibratorHelper;
+ private final SystemClock mSystemClock;
+ private final CoroutineDispatcher mMainDispatcher;
@Inject
- public Factory(FalsingManager falsingManager, UiEventLogger uiEventLogger) {
+ public Factory(
+ FalsingManager falsingManager,
+ UiEventLogger uiEventLogger,
+ VibratorHelper vibratorHelper,
+ SystemClock clock,
+ FeatureFlagsClassic featureFlags,
+ @Main CoroutineDispatcher mainDispatcher
+ ) {
mFalsingManager = falsingManager;
mUiEventLogger = uiEventLogger;
+ mFeatureFlags = featureFlags;
+ mVibratorHelper = vibratorHelper;
+ mSystemClock = clock;
+ mMainDispatcher = mainDispatcher;
}
/**
@@ -262,7 +314,17 @@ public class BrightnessSliderController extends ViewController<BrightnessSliderV
int layout = getLayout();
BrightnessSliderView root = (BrightnessSliderView) LayoutInflater.from(context)
.inflate(layout, viewRoot, false);
- return new BrightnessSliderController(root, mFalsingManager, mUiEventLogger);
+ BrightnessSliderHapticPlugin plugin;
+ if (mFeatureFlags.isEnabled(HAPTIC_BRIGHTNESS_SLIDER)) {
+ plugin = new BrightnessSliderHapticPluginImpl(
+ mVibratorHelper,
+ mSystemClock,
+ mMainDispatcher
+ );
+ } else {
+ plugin = new BrightnessSliderHapticPlugin() {};
+ }
+ return new BrightnessSliderController(root, mFalsingManager, mUiEventLogger, plugin);
}
/** Get the layout to inflate based on what slider to use */
diff --git a/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPlugin.kt b/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPlugin.kt
new file mode 100644
index 000000000000..f77511420491
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPlugin.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.settings.brightness
+
+import android.view.VelocityTracker
+import com.android.systemui.haptics.slider.SeekableSliderEventProducer
+
+/** Plugin component for the System UI brightness slider to incorporate dynamic haptics */
+interface BrightnessSliderHapticPlugin {
+
+ /** Finger velocity tracker */
+ val velocityTracker: VelocityTracker?
+ get() = null
+
+ /** Producer of slider events from the underlying [android.widget.SeekBar] */
+ val seekableSliderEventProducer: SeekableSliderEventProducer?
+ get() = null
+
+ /**
+ * Start the plugin.
+ *
+ * This starts the tracking of slider states, events and triggering of haptic feedback.
+ */
+ fun start() {}
+
+ /**
+ * Stop the plugin
+ *
+ * This stops the tracking of slider states, events and triggers of haptic feedback.
+ */
+ fun stop() {}
+}
diff --git a/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImpl.kt b/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImpl.kt
new file mode 100644
index 000000000000..32561f0b4c4f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImpl.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.settings.brightness
+
+import android.view.VelocityTracker
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.haptics.slider.SeekableSliderEventProducer
+import com.android.systemui.haptics.slider.SeekableSliderTracker
+import com.android.systemui.haptics.slider.SliderHapticFeedbackProvider
+import com.android.systemui.haptics.slider.SliderTracker
+import com.android.systemui.statusbar.VibratorHelper
+import com.android.systemui.util.time.SystemClock
+import kotlinx.coroutines.CoroutineDispatcher
+
+/**
+ * Implementation of the [BrightnessSliderHapticPlugin].
+ *
+ * For the specifics of the brightness slider in System UI, 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 BrightnessSliderHapticPluginImpl
+@JvmOverloads
+constructor(
+ vibratorHelper: VibratorHelper,
+ systemClock: SystemClock,
+ @Main mainDispatcher: CoroutineDispatcher,
+ override val velocityTracker: VelocityTracker = VelocityTracker.obtain(),
+ override val seekableSliderEventProducer: SeekableSliderEventProducer =
+ SeekableSliderEventProducer(),
+) : BrightnessSliderHapticPlugin {
+
+ private val sliderHapticFeedbackProvider: SliderHapticFeedbackProvider =
+ SliderHapticFeedbackProvider(vibratorHelper, velocityTracker, clock = systemClock)
+ private val sliderTracker: SliderTracker =
+ SeekableSliderTracker(
+ sliderHapticFeedbackProvider,
+ seekableSliderEventProducer,
+ mainDispatcher,
+ )
+
+ val isTracking: Boolean
+ get() = sliderTracker.isTracking
+
+ override fun start() {
+ if (!sliderTracker.isTracking) {
+ sliderTracker.startTracking()
+ }
+ }
+
+ override fun stop() {
+ sliderTracker.stopTracking()
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java b/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java
index 645595c1f7bf..d089252759c7 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java
@@ -118,6 +118,16 @@ public class VibratorHelper {
}
/**
+ * @see Vibrator#vibrate(VibrationEffect, VibrationAttributes)
+ */
+ public void vibrate(@NonNull VibrationEffect effect, @NonNull VibrationAttributes attributes) {
+ if (!hasVibrator()) {
+ return;
+ }
+ mExecutor.execute(() -> mVibrator.vibrate(effect, attributes));
+ }
+
+ /**
* @see Vibrator#hasVibrator()
*/
public boolean hasVibrator() {
@@ -154,6 +164,17 @@ public class VibratorHelper {
}
/**
+ * @see Vibrator#getPrimitiveDurations(int...)
+ */
+ public int[] getPrimitiveDurations(int... primitiveIds) {
+ if (!hasVibrator()) {
+ return new int[]{0};
+ } else {
+ return mVibrator.getPrimitiveDurations(primitiveIds);
+ }
+ }
+
+ /**
* Perform a vibration using a view and the one-way API with flags
* @see View#performHapticFeedback(int feedbackConstant, int flags)
*/
diff --git a/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/FakeSliderEventProducer.kt b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/FakeSliderEventProducer.kt
new file mode 100644
index 000000000000..9deabc76065c
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/FakeSliderEventProducer.kt
@@ -0,0 +1,32 @@
+/*
+ * 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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Fake implementation of a slider event producer */
+class FakeSliderEventProducer : SliderEventProducer {
+
+ private val _currentEvent = MutableStateFlow(SliderEvent(SliderEventType.NOTHING, 0f))
+
+ fun sendEvent(event: SliderEvent) {
+ _currentEvent.value = event
+ }
+ override fun produceEvents(): Flow<SliderEvent> = _currentEvent.asStateFlow()
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SeekableSliderTrackerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SeekableSliderTrackerTest.kt
new file mode 100644
index 000000000000..add601c4b1b3
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SeekableSliderTrackerTest.kt
@@ -0,0 +1,547 @@
+/*
+ * 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.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestCoroutineScheduler
+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
+@RunWith(AndroidJUnit4::class)
+class SeekableSliderTrackerTest : SysuiTestCase() {
+
+ @Mock private lateinit var sliderStateListener: SliderStateListener
+ private val sliderEventProducer = FakeSliderEventProducer()
+ private lateinit var mSeekableSliderTracker: SeekableSliderTracker
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+ }
+
+ @Test
+ fun initializeSliderTracker_startsTracking() = runTest {
+ // GIVEN Initialized tracker
+ initTracker(testScheduler)
+
+ // THEN the tracker job is active
+ assertThat(mSeekableSliderTracker.isTracking).isTrue()
+ }
+
+ @Test
+ fun stopTracking_onAnyState_resetsToIdle() = runTest {
+ enumValues<SliderState>().forEach {
+ // GIVEN Initialized tracker
+ initTracker(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(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(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(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(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(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 at threshold
+ progress += config.jumpThreshold
+ 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(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()
+ verify(sliderStateListener).onUpperBookend()
+ verifyNoMoreInteractions(sliderStateListener)
+ }
+
+ @Test
+ fun lowerBookendSelection_onWait_movesToBookendSelected() = runTest {
+ val config = SeekableSliderTrackerConfig()
+ initTracker(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()
+ verify(sliderStateListener).onLowerBookend()
+ verifyNoMoreInteractions(sliderStateListener)
+ }
+
+ @Test
+ fun stopTracking_onWait_whenWaitingJobIsActive_resetsToIdle() = runTest {
+ val config = SeekableSliderTrackerConfig()
+ initTracker(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun initTracker(
+ scheduler: TestCoroutineScheduler,
+ config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
+ ) {
+ mSeekableSliderTracker =
+ SeekableSliderTracker(
+ sliderStateListener,
+ sliderEventProducer,
+ UnconfinedTestDispatcher(scheduler),
+ config
+ )
+ mSeekableSliderTracker.startTracking()
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt
new file mode 100644
index 000000000000..0ee348e0d23d
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt
@@ -0,0 +1,247 @@
+/*
+ * 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.os.VibrationAttributes
+import android.os.VibrationEffect
+import android.view.VelocityTracker
+import android.view.animation.AccelerateInterpolator
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.statusbar.VibratorHelper
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.eq
+import com.android.systemui.util.mockito.whenever
+import com.android.systemui.util.time.FakeSystemClock
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class SliderHapticFeedbackProviderTest : SysuiTestCase() {
+
+ @Mock private lateinit var velocityTracker: VelocityTracker
+ @Mock private lateinit var vibratorHelper: VibratorHelper
+
+ private val config = SliderHapticFeedbackConfig()
+ private val clock = FakeSystemClock()
+
+ private val lowTickDuration = 12 // Mocked duration of a low tick
+ private val dragTextureThresholdMillis =
+ lowTickDuration * config.numberOfLowTicks + config.deltaMillisForDragInterval
+ private val progressInterpolator = AccelerateInterpolator(config.progressInterpolatorFactor)
+ private val velocityInterpolator = AccelerateInterpolator(config.velocityInterpolatorFactor)
+ private lateinit var sliderHapticFeedbackProvider: SliderHapticFeedbackProvider
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+ whenever(vibratorHelper.getPrimitiveDurations(any()))
+ .thenReturn(intArrayOf(lowTickDuration))
+ whenever(velocityTracker.xVelocity).thenReturn(config.maxVelocityToScale)
+ sliderHapticFeedbackProvider =
+ SliderHapticFeedbackProvider(vibratorHelper, velocityTracker, config, clock)
+ }
+
+ @Test
+ fun playHapticAtLowerBookend_playsClick() {
+ val vibration =
+ VibrationEffect.startComposition()
+ .addPrimitive(
+ VibrationEffect.Composition.PRIMITIVE_CLICK,
+ scaleAtBookends(config.maxVelocityToScale)
+ )
+ .compose()
+
+ sliderHapticFeedbackProvider.onLowerBookend()
+
+ verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java))
+ }
+
+ @Test
+ fun playHapticAtLowerBookend_twoTimes_playsClickOnlyOnce() {
+ val vibration =
+ VibrationEffect.startComposition()
+ .addPrimitive(
+ VibrationEffect.Composition.PRIMITIVE_CLICK,
+ scaleAtBookends(config.maxVelocityToScale)
+ )
+ .compose()
+
+ sliderHapticFeedbackProvider.onLowerBookend()
+ sliderHapticFeedbackProvider.onLowerBookend()
+
+ verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java))
+ }
+
+ @Test
+ fun playHapticAtUpperBookend_playsClick() {
+ val vibration =
+ VibrationEffect.startComposition()
+ .addPrimitive(
+ VibrationEffect.Composition.PRIMITIVE_CLICK,
+ scaleAtBookends(config.maxVelocityToScale)
+ )
+ .compose()
+
+ sliderHapticFeedbackProvider.onUpperBookend()
+
+ verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java))
+ }
+
+ @Test
+ fun playHapticAtUpperBookend_twoTimes_playsClickOnlyOnce() {
+ val vibration =
+ VibrationEffect.startComposition()
+ .addPrimitive(
+ VibrationEffect.Composition.PRIMITIVE_CLICK,
+ scaleAtBookends(config.maxVelocityToScale)
+ )
+ .compose()
+
+ sliderHapticFeedbackProvider.onUpperBookend()
+ sliderHapticFeedbackProvider.onUpperBookend()
+
+ verify(vibratorHelper, times(1))
+ .vibrate(eq(vibration), any(VibrationAttributes::class.java))
+ }
+
+ @Test
+ fun playHapticAtProgress_onQuickSuccession_playsLowTicksOnce() {
+ // GIVEN max velocity and slider progress
+ val progress = 1f
+ val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
+ val ticks = VibrationEffect.startComposition()
+ repeat(config.numberOfLowTicks) {
+ ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
+ }
+
+ // GIVEN system running for 1s
+ clock.advanceTime(1000)
+
+ // WHEN two calls to play occur immediately
+ sliderHapticFeedbackProvider.onProgress(progress)
+ sliderHapticFeedbackProvider.onProgress(progress)
+
+ // THEN the correct composition only plays once
+ verify(vibratorHelper, times(1))
+ .vibrate(eq(ticks.compose()), any(VibrationAttributes::class.java))
+ }
+
+ @Test
+ fun playHapticAtProgress_afterNextDragThreshold_playsLowTicksTwice() {
+ // GIVEN max velocity and slider progress
+ val progress = 1f
+ val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
+ val ticks = VibrationEffect.startComposition()
+ repeat(config.numberOfLowTicks) {
+ ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
+ }
+
+ // GIVEN system running for 1s
+ clock.advanceTime(1000)
+
+ // WHEN two calls to play occur with the required threshold separation
+ sliderHapticFeedbackProvider.onProgress(progress)
+ clock.advanceTime(dragTextureThresholdMillis.toLong())
+ sliderHapticFeedbackProvider.onProgress(progress)
+
+ // THEN the correct composition plays two times
+ verify(vibratorHelper, times(2))
+ .vibrate(eq(ticks.compose()), any(VibrationAttributes::class.java))
+ }
+
+ @Test
+ fun playHapticAtLowerBookend_afterPlayingAtProgress_playsTwice() {
+ // GIVEN max velocity and slider progress
+ val progress = 1f
+ val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
+ val ticks = VibrationEffect.startComposition()
+ repeat(config.numberOfLowTicks) {
+ ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
+ }
+ val bookendVibration =
+ VibrationEffect.startComposition()
+ .addPrimitive(
+ VibrationEffect.Composition.PRIMITIVE_CLICK,
+ scaleAtBookends(config.maxVelocityToScale)
+ )
+ .compose()
+
+ // GIVEN a vibration at the lower bookend followed by a request to vibrate at progress
+ sliderHapticFeedbackProvider.onLowerBookend()
+ sliderHapticFeedbackProvider.onProgress(progress)
+
+ // WHEN a vibration is to trigger again at the lower bookend
+ sliderHapticFeedbackProvider.onLowerBookend()
+
+ // THEN there are two bookend vibrations
+ verify(vibratorHelper, times(2))
+ .vibrate(eq(bookendVibration), any(VibrationAttributes::class.java))
+ }
+
+ @Test
+ fun playHapticAtUpperBookend_afterPlayingAtProgress_playsTwice() {
+ // GIVEN max velocity and slider progress
+ val progress = 1f
+ val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
+ val ticks = VibrationEffect.startComposition()
+ repeat(config.numberOfLowTicks) {
+ ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
+ }
+ val bookendVibration =
+ VibrationEffect.startComposition()
+ .addPrimitive(
+ VibrationEffect.Composition.PRIMITIVE_CLICK,
+ scaleAtBookends(config.maxVelocityToScale)
+ )
+ .compose()
+
+ // GIVEN a vibration at the upper bookend followed by a request to vibrate at progress
+ sliderHapticFeedbackProvider.onUpperBookend()
+ sliderHapticFeedbackProvider.onProgress(progress)
+
+ // WHEN a vibration is to trigger again at the upper bookend
+ sliderHapticFeedbackProvider.onUpperBookend()
+
+ // THEN there are two bookend vibrations
+ verify(vibratorHelper, times(2))
+ .vibrate(eq(bookendVibration), any(VibrationAttributes::class.java))
+ }
+
+ private fun scaleAtBookends(velocity: Float): Float {
+ val range = config.upperBookendScale - config.lowerBookendScale
+ val interpolatedVelocity =
+ velocityInterpolator.getInterpolation(velocity / config.maxVelocityToScale)
+ return interpolatedVelocity * range + config.lowerBookendScale
+ }
+
+ private fun scaleAtProgressChange(velocity: Float, progress: Float): Float {
+ val range = config.progressBasedDragMaxScale - config.progressBasedDragMinScale
+ val interpolatedVelocity =
+ velocityInterpolator.getInterpolation(velocity / config.maxVelocityToScale)
+ val interpolatedProgress = progressInterpolator.getInterpolation(progress)
+ val bump = interpolatedVelocity * config.additionalVelocityMaxBump
+ return interpolatedProgress * range + config.progressBasedDragMinScale + bump
+ }
+}
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 d75405fe503f..707a2971b1eb 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
@@ -40,7 +40,6 @@ import org.mockito.Mock
import org.mockito.Mockito.isNull
import org.mockito.Mockito.never
import org.mockito.Mockito.notNull
-import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
import org.mockito.Mockito.`when` as whenever
@@ -61,6 +60,8 @@ class BrightnessSliderControllerTest : SysuiTestCase() {
private lateinit var motionEvent: MotionEvent
@Mock
private lateinit var listener: ToggleSlider.Listener
+ @Mock
+ private lateinit var mBrightnessSliderHapticPlugin: BrightnessSliderHapticPlugin
@Captor
private lateinit var seekBarChangeCaptor: ArgumentCaptor<SeekBar.OnSeekBarChangeListener>
@@ -79,7 +80,12 @@ class BrightnessSliderControllerTest : SysuiTestCase() {
whenever(motionEvent.copy()).thenReturn(motionEvent)
mController =
- BrightnessSliderController(brightnessSliderView, mFalsingManager, uiEventLogger)
+ BrightnessSliderController(
+ brightnessSliderView,
+ mFalsingManager,
+ uiEventLogger,
+ mBrightnessSliderHapticPlugin,
+ )
mController.init()
mController.setOnChangedListener(listener)
}
@@ -94,6 +100,7 @@ class BrightnessSliderControllerTest : SysuiTestCase() {
mController.onViewAttached()
verify(brightnessSliderView).setOnSeekBarChangeListener(notNull())
+ verify(mBrightnessSliderHapticPlugin).start()
}
@Test
@@ -103,6 +110,7 @@ class BrightnessSliderControllerTest : SysuiTestCase() {
verify(brightnessSliderView).setOnSeekBarChangeListener(isNull())
verify(brightnessSliderView).setOnDispatchTouchEventListener(isNull())
+ verify(mBrightnessSliderHapticPlugin).stop()
}
@Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImplTest.kt
new file mode 100644
index 000000000000..51629b5c01e3
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/settings/brightness/BrightnessSliderHapticPluginImplTest.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.settings.brightness
+
+import android.view.VelocityTracker
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.haptics.slider.SeekableSliderEventProducer
+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.CoroutineDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class BrightnessSliderHapticPluginImplTest : SysuiTestCase() {
+
+ @Mock private lateinit var vibratorHelper: VibratorHelper
+ @Mock private lateinit var velocityTracker: VelocityTracker
+ @Mock private lateinit var mainDispatcher: CoroutineDispatcher
+
+ private val systemClock = FakeSystemClock()
+ private val sliderEventProducer = SeekableSliderEventProducer()
+
+ private lateinit var plugin: BrightnessSliderHapticPluginImpl
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+ whenever(vibratorHelper.getPrimitiveDurations(anyInt())).thenReturn(intArrayOf(0))
+ }
+
+ @Test
+ fun start_beginsTrackingSlider() = runTest {
+ createPlugin(UnconfinedTestDispatcher(testScheduler))
+ plugin.start()
+
+ assertThat(plugin.isTracking).isTrue()
+ }
+
+ @Test
+ fun stop_stopsTrackingSlider() = runTest {
+ createPlugin(UnconfinedTestDispatcher(testScheduler))
+ // GIVEN that the plugin started the tracking component
+ plugin.start()
+
+ // WHEN called to stop
+ plugin.stop()
+
+ // THEN the tracking component stops
+ assertThat(plugin.isTracking).isFalse()
+ }
+
+ @Test
+ fun start_afterStop_startsTheTrackingAgain() = runTest {
+ createPlugin(UnconfinedTestDispatcher(testScheduler))
+ // GIVEN that the plugin started the tracking component
+ plugin.start()
+
+ // WHEN the plugin is restarted
+ plugin.stop()
+ plugin.start()
+
+ // THEN the tracking begins again
+ assertThat(plugin.isTracking).isTrue()
+ }
+
+ private fun createPlugin(dispatcher: CoroutineDispatcher) {
+ plugin =
+ BrightnessSliderHapticPluginImpl(
+ vibratorHelper,
+ systemClock,
+ dispatcher,
+ velocityTracker,
+ sliderEventProducer,
+ )
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt
index aab4bc361d7b..b90582575970 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt
@@ -75,6 +75,18 @@ class VibratorHelperTest : SysuiTestCase() {
}
@Test
+ fun testVibrate5() {
+ vibratorHelper.vibrate(
+ mock(VibrationEffect::class.java),
+ mock(VibrationAttributes::class.java)
+ )
+ verifyAsync().vibrate(
+ any(VibrationEffect::class.java),
+ any(VibrationAttributes::class.java)
+ )
+ }
+
+ @Test
fun testPerformHapticFeedback() {
val constant = HapticFeedbackConstants.CONFIRM
vibratorHelper.performHapticFeedback(view, constant)