1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.haptics.slider
18 
19 import android.os.VibrationAttributes
20 import android.os.VibrationEffect
21 import android.view.VelocityTracker
22 import android.view.animation.AccelerateInterpolator
23 import androidx.annotation.FloatRange
24 import androidx.annotation.VisibleForTesting
25 import com.android.systemui.statusbar.VibratorHelper
26 import kotlin.math.abs
27 import kotlin.math.min
28 import kotlin.math.pow
29 
30 /**
31  * Listener of slider events that triggers haptic feedback.
32  *
33  * @property[vibratorHelper] Singleton instance of the [VibratorHelper] to deliver haptics.
34  * @property[velocityTracker] Instance of a [VelocityTracker] that tracks slider dragging velocity.
35  * @property[config] Configuration parameters for vibration encapsulated as a
36  *   [SliderHapticFeedbackConfig].
37  * @property[clock] Clock to obtain elapsed real time values.
38  */
39 class SliderHapticFeedbackProvider(
40     private val vibratorHelper: VibratorHelper,
41     private val velocityTracker: VelocityTracker,
42     private val config: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(),
43     private val clock: com.android.systemui.util.time.SystemClock,
44 ) : SliderStateListener {
45 
46     private val velocityAccelerateInterpolator =
47         AccelerateInterpolator(config.velocityInterpolatorFactor)
48     private val positionAccelerateInterpolator =
49         AccelerateInterpolator(config.progressInterpolatorFactor)
50     private var dragTextureLastTime = clock.elapsedRealtime()
51     var dragTextureLastProgress = -1f
52         private set
53     private val lowTickDurationMs =
54         vibratorHelper.getPrimitiveDurations(VibrationEffect.Composition.PRIMITIVE_LOW_TICK)[0]
55     private var hasVibratedAtLowerBookend = false
56     private var hasVibratedAtUpperBookend = false
57 
58     /** Time threshold to wait before making new API call. */
59     private val thresholdUntilNextDragCallMillis =
60         lowTickDurationMs * config.numberOfLowTicks + config.deltaMillisForDragInterval
61 
62     /**
63      * Vibrate when the handle reaches either bookend with a certain velocity.
64      *
65      * @param[absoluteVelocity] Velocity of the handle when it reached the bookend.
66      */
vibrateOnEdgeCollisionnull67     private fun vibrateOnEdgeCollision(absoluteVelocity: Float) {
68         val powerScale = scaleOnEdgeCollision(absoluteVelocity)
69         val vibration =
70             VibrationEffect.startComposition()
71                 .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, powerScale)
72                 .compose()
73         vibratorHelper.vibrate(vibration, VIBRATION_ATTRIBUTES_PIPELINING)
74     }
75 
76     /**
77      * Get the velocity-based scale at the bookends
78      *
79      * @param[absoluteVelocity] Velocity of the handle when it reached the bookend.
80      * @return The power scale for the vibration.
81      */
82     @VisibleForTesting
scaleOnEdgeCollisionnull83     fun scaleOnEdgeCollision(absoluteVelocity: Float): Float {
84         val velocityInterpolated =
85             velocityAccelerateInterpolator.getInterpolation(
86                 min(absoluteVelocity / config.maxVelocityToScale, 1f)
87             )
88         val bookendScaleRange = config.upperBookendScale - config.lowerBookendScale
89         val bookendsHitScale = bookendScaleRange * velocityInterpolated + config.lowerBookendScale
90         return bookendsHitScale.pow(config.exponent)
91     }
92 
93     /**
94      * Create a drag texture vibration based on velocity and slider progress.
95      *
96      * @param[absoluteVelocity] Absolute velocity of the handle.
97      * @param[normalizedSliderProgress] Progress of the slider handled normalized to the range from
98      *   0F to 1F (inclusive).
99      */
vibrateDragTexturenull100     private fun vibrateDragTexture(
101         absoluteVelocity: Float,
102         @FloatRange(from = 0.0, to = 1.0) normalizedSliderProgress: Float
103     ) {
104         // Check if its time to vibrate
105         val currentTime = clock.elapsedRealtime()
106         val elapsedSinceLastDrag = currentTime - dragTextureLastTime
107         if (elapsedSinceLastDrag < thresholdUntilNextDragCallMillis) return
108 
109         val deltaProgress = abs(normalizedSliderProgress - dragTextureLastProgress)
110         if (deltaProgress < config.deltaProgressForDragThreshold) return
111 
112         val powerScale = scaleOnDragTexture(absoluteVelocity, normalizedSliderProgress)
113 
114         // Trigger the vibration composition
115         val composition = VibrationEffect.startComposition()
116         repeat(config.numberOfLowTicks) {
117             composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, powerScale)
118         }
119         vibratorHelper.vibrate(composition.compose(), VIBRATION_ATTRIBUTES_PIPELINING)
120         dragTextureLastTime = currentTime
121         dragTextureLastProgress = normalizedSliderProgress
122     }
123 
124     /**
125      * Get the scale of the drag texture vibration.
126      *
127      * @param[absoluteVelocity] Absolute velocity of the handle.
128      * @param[normalizedSliderProgress] Progress of the slider handled normalized to the range from
129      *   0F to 1F (inclusive).
130      *     @return the scale of the vibration.
131      */
132     @VisibleForTesting
scaleOnDragTexturenull133     fun scaleOnDragTexture(
134         absoluteVelocity: Float,
135         @FloatRange(from = 0.0, to = 1.0) normalizedSliderProgress: Float
136     ): Float {
137         val velocityInterpolated =
138             velocityAccelerateInterpolator.getInterpolation(
139                 min(absoluteVelocity / config.maxVelocityToScale, 1f)
140             )
141 
142         // Scaling of vibration due to the position of the slider
143         val positionScaleRange = config.progressBasedDragMaxScale - config.progressBasedDragMinScale
144         val sliderProgressInterpolated =
145             positionAccelerateInterpolator.getInterpolation(normalizedSliderProgress)
146         val positionBasedScale =
147             positionScaleRange * sliderProgressInterpolated + config.progressBasedDragMinScale
148 
149         // Scaling bump due to velocity
150         val velocityBasedScale = velocityInterpolated * config.additionalVelocityMaxBump
151 
152         // Total scale
153         val scale = positionBasedScale + velocityBasedScale
154         return scale.pow(config.exponent)
155     }
156 
onHandleAcquiredByTouchnull157     override fun onHandleAcquiredByTouch() {}
158 
onHandleReleasedFromTouchnull159     override fun onHandleReleasedFromTouch() {
160         dragTextureLastProgress = -1f
161     }
162 
onLowerBookendnull163     override fun onLowerBookend() {
164         if (!hasVibratedAtLowerBookend) {
165             vibrateOnEdgeCollision(abs(getTrackedVelocity()))
166             hasVibratedAtLowerBookend = true
167         }
168     }
169 
onUpperBookendnull170     override fun onUpperBookend() {
171         if (!hasVibratedAtUpperBookend) {
172             vibrateOnEdgeCollision(abs(getTrackedVelocity()))
173             hasVibratedAtUpperBookend = true
174         }
175     }
176 
onProgressnull177     override fun onProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) {
178         vibrateDragTexture(abs(getTrackedVelocity()), progress)
179         hasVibratedAtUpperBookend = false
180         hasVibratedAtLowerBookend = false
181     }
182 
getTrackedVelocitynull183     private fun getTrackedVelocity(): Float {
184         velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
185         return if (velocityTracker.isAxisSupported(config.velocityAxis)) {
186             velocityTracker.getAxisVelocity(config.velocityAxis)
187         } else {
188             0f
189         }
190     }
191 
onProgressJumpnull192     override fun onProgressJump(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}
193 
onSelectAndArrownull194     override fun onSelectAndArrow(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}
195 
196     private companion object {
197         private val VIBRATION_ATTRIBUTES_PIPELINING =
198             VibrationAttributes.Builder()
199                 .setUsage(VibrationAttributes.USAGE_TOUCH)
200                 .setFlags(VibrationAttributes.FLAG_PIPELINED_EFFECT)
201                 .build()
202         private const val UNITS_SECOND = 1000
203     }
204 }
205