• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2024 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.view.MotionEvent
20 import android.view.VelocityTracker
21 import androidx.annotation.VisibleForTesting
22 import com.android.app.tracing.coroutines.launchTraced as launch
23 import com.android.systemui.statusbar.VibratorHelper
24 import com.android.systemui.util.time.SystemClock
25 import com.google.android.msdl.domain.MSDLPlayer
26 import kotlinx.coroutines.CoroutineScope
27 import kotlinx.coroutines.Job
28 import kotlinx.coroutines.delay
29 
30 /**
31  * A plugin added to a manager of a [HapticSlider] that adds dynamic haptic feedback.
32  *
33  * A [SliderStateProducer] is used as the producer of slider events, a
34  * [SliderHapticFeedbackProvider] is used as the listener of slider states to play haptic feedback
35  * depending on the state, and a [SliderStateTracker] is used as the state machine handler that
36  * tracks and manipulates the slider state.
37  */
38 class HapticSliderPlugin
39 @JvmOverloads
40 constructor(
41     vibratorHelper: VibratorHelper,
42     msdlPlayer: MSDLPlayer,
43     systemClock: SystemClock,
44     private val slider: HapticSlider,
45     sliderHapticFeedbackConfig: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(),
46     private val sliderTrackerConfig: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
47 ) {
48 
49     private val velocityTracker = VelocityTracker.obtain()
50 
<lambda>null51     private val dragVelocityProvider = SliderDragVelocityProvider {
52         velocityTracker.computeCurrentVelocity(
53             UNITS_SECOND,
54             sliderHapticFeedbackConfig.maxVelocityToScale,
55         )
56         if (velocityTracker.isAxisSupported(sliderHapticFeedbackConfig.velocityAxis)) {
57             velocityTracker.getAxisVelocity(sliderHapticFeedbackConfig.velocityAxis)
58         } else {
59             0f
60         }
61     }
62 
63     private val sliderEventProducer = SliderStateProducer()
64 
65     private val sliderHapticFeedbackProvider =
66         SliderHapticFeedbackProvider(
67             vibratorHelper,
68             msdlPlayer,
69             dragVelocityProvider,
70             sliderHapticFeedbackConfig,
71             systemClock,
72         )
73 
74     private var sliderTracker: SliderStateTracker? = null
75 
76     private var pluginScope: CoroutineScope? = null
77 
78     val isTracking: Boolean
79         get() = sliderTracker?.isTracking == true
80 
81     val trackerState: SliderState?
82         get() = sliderTracker?.currentState
83 
84     /**
85      * A waiting [Job] for a timer that estimates the key-up event when a key-down event is
86      * received.
87      *
88      * This is useful for the cases where the slider is being operated by an external key, but the
89      * release of the key is not easily accessible (e.g., the volume keys)
90      */
91     private var keyUpJob: Job? = null
92 
93     @VisibleForTesting
94     val isKeyUpTimerWaiting: Boolean
95         get() = keyUpJob != null && keyUpJob?.isActive == true
96 
97     /**
98      * Specify the scope for the plugin's operations and start the slider tracker in this scope.
99      * This also involves the key-up timer job.
100      */
startInScopenull101     fun startInScope(scope: CoroutineScope) {
102         if (sliderTracker != null) stop()
103         sliderTracker =
104             SliderStateTracker(
105                 sliderHapticFeedbackProvider,
106                 sliderEventProducer,
107                 scope,
108                 sliderTrackerConfig,
109             )
110         pluginScope = scope
111         sliderTracker?.startTracking()
112     }
113 
114     /**
115      * Stop the plugin
116      *
117      * This stops the tracking of slider states, events and triggers of haptic feedback.
118      */
stopnull119     fun stop() = sliderTracker?.stopTracking()
120 
121     /** React to a touch event */
122     fun onTouchEvent(event: MotionEvent?) {
123         when (event?.actionMasked) {
124             MotionEvent.ACTION_UP,
125             MotionEvent.ACTION_CANCEL -> velocityTracker.clear()
126             MotionEvent.ACTION_DOWN,
127             MotionEvent.ACTION_MOVE -> velocityTracker.addMovement(event)
128         }
129     }
130 
131     /** onStartTrackingTouch event from the slider. */
onStartTrackingTouchnull132     fun onStartTrackingTouch() {
133         if (isTracking) {
134             sliderEventProducer.onStartTracking(true)
135         }
136     }
137 
138     /** onProgressChanged event from the slider's. */
onProgressChangednull139     fun onProgressChanged(progress: Int, fromUser: Boolean) {
140         if (isTracking) {
141             if (sliderTracker?.currentState == SliderState.IDLE && !fromUser) {
142                 // This case translates to the slider starting to track program changes
143                 sliderEventProducer.resetWithProgress(normalizeProgress(slider, progress))
144                 sliderEventProducer.onStartTracking(false)
145             } else {
146                 sliderEventProducer.onProgressChanged(fromUser, normalizeProgress(slider, progress))
147             }
148         }
149     }
150 
151     /**
152      * Normalize the integer progress of a HapticSlider to the range from 0F to 1F.
153      *
154      * @param[slider] The HapticSlider that reports a progress.
155      * @param[progress] The integer progress of the HapticSlider within its min and max values.
156      * @return The progress in the range from 0F to 1F.
157      */
normalizeProgressnull158     private fun normalizeProgress(slider: HapticSlider, progress: Int): Float {
159         if (slider.max == slider.min) {
160             return 1.0f
161         }
162         return (progress - slider.min) / (slider.max - slider.min)
163     }
164 
165     /** onStopTrackingTouch event from the slider. */
onStopTrackingTouchnull166     fun onStopTrackingTouch() {
167         if (isTracking) {
168             sliderEventProducer.onStopTracking(true)
169         }
170     }
171 
172     /** Programmatic changes have stopped */
onStoppedTrackingProgramnull173     private fun onStoppedTrackingProgram() {
174         if (isTracking) {
175             sliderEventProducer.onStopTracking(false)
176         }
177     }
178 
179     /**
180      * An external key was pressed (e.g., a volume key).
181      *
182      * This event is used to estimate the key-up event based on a running a timer as a waiting
183      * coroutine in the [pluginScope]. A key-up event in a slider corresponds to an onArrowUp event.
184      * Therefore, [onStoppedTrackingProgram] must be called after the timeout.
185      */
onKeyDownnull186     fun onKeyDown() {
187         if (!isTracking) return
188 
189         if (isKeyUpTimerWaiting) {
190             // Cancel the ongoing wait
191             keyUpJob?.cancel()
192         }
193         keyUpJob =
194             pluginScope?.launch {
195                 delay(KEY_UP_TIMEOUT)
196                 onStoppedTrackingProgram()
197             }
198     }
199 
200     companion object {
201         const val KEY_UP_TIMEOUT = 60L
202         private const val UNITS_SECOND = 1000
203     }
204 }
205