diff options
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) |