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 android.widget.SeekBar
22 import androidx.annotation.VisibleForTesting
23 import com.android.systemui.statusbar.VibratorHelper
24 import com.android.systemui.util.time.SystemClock
25 import kotlinx.coroutines.CoroutineScope
26 import kotlinx.coroutines.Job
27 import kotlinx.coroutines.delay
28 import kotlinx.coroutines.launch
29 
30 /**
31  * A plugin added to a manager of a [android.widget.SeekBar] 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 SeekbarHapticPlugin
39 @JvmOverloads
40 constructor(
41     vibratorHelper: VibratorHelper,
42     systemClock: SystemClock,
43     sliderHapticFeedbackConfig: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(),
44     private val sliderTrackerConfig: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
45 ) {
46 
47     private val velocityTracker = VelocityTracker.obtain()
48 
49     private val sliderEventProducer = SliderStateProducer()
50 
51     private val sliderHapticFeedbackProvider =
52         SliderHapticFeedbackProvider(
53             vibratorHelper,
54             velocityTracker,
55             sliderHapticFeedbackConfig,
56             systemClock,
57         )
58 
59     private var sliderTracker: SliderStateTracker? = null
60 
61     private var pluginScope: CoroutineScope? = null
62 
63     val isTracking: Boolean
64         get() = sliderTracker?.isTracking == true
65 
66     val trackerState: SliderState?
67         get() = sliderTracker?.currentState
68 
69     /**
70      * A waiting [Job] for a timer that estimates the key-up event when a key-down event is
71      * received.
72      *
73      * This is useful for the cases where the slider is being operated by an external key, but the
74      * release of the key is not easily accessible (e.g., the volume keys)
75      */
76     private var keyUpJob: Job? = null
77 
78     @VisibleForTesting
79     val isKeyUpTimerWaiting: Boolean
80         get() = keyUpJob != null && keyUpJob?.isActive == true
81 
82     /**
83      * Specify the scope for the plugin's operations and start the slider tracker in this scope.
84      * This also involves the key-up timer job.
85      */
startInScopenull86     fun startInScope(scope: CoroutineScope) {
87         if (sliderTracker != null) stop()
88         sliderTracker =
89             SliderStateTracker(
90                 sliderHapticFeedbackProvider,
91                 sliderEventProducer,
92                 scope,
93                 sliderTrackerConfig,
94             )
95         pluginScope = scope
96         sliderTracker?.startTracking()
97     }
98 
99     /**
100      * Stop the plugin
101      *
102      * This stops the tracking of slider states, events and triggers of haptic feedback.
103      */
stopnull104     fun stop() = sliderTracker?.stopTracking()
105 
106     /** React to a touch event */
107     fun onTouchEvent(event: MotionEvent?) {
108         when (event?.actionMasked) {
109             MotionEvent.ACTION_UP,
110             MotionEvent.ACTION_CANCEL -> velocityTracker.clear()
111             MotionEvent.ACTION_DOWN,
112             MotionEvent.ACTION_MOVE -> velocityTracker.addMovement(event)
113         }
114     }
115 
116     /** onStartTrackingTouch event from the slider's [android.widget.SeekBar] */
onStartTrackingTouchnull117     fun onStartTrackingTouch(seekBar: SeekBar) {
118         if (isTracking) {
119             sliderEventProducer.onStartTracking(true)
120         }
121     }
122 
123     /** onProgressChanged event from the slider's [android.widget.SeekBar] */
onProgressChangednull124     fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
125         if (isTracking) {
126             if (sliderTracker?.currentState == SliderState.IDLE && !fromUser) {
127                 // This case translates to the slider starting to track program changes
128                 sliderEventProducer.resetWithProgress(normalizeProgress(seekBar, progress))
129                 sliderEventProducer.onStartTracking(false)
130             } else {
131                 sliderEventProducer.onProgressChanged(
132                     fromUser,
133                     normalizeProgress(seekBar, progress),
134                 )
135             }
136         }
137     }
138 
139     /**
140      * Normalize the integer progress of a SeekBar to the range from 0F to 1F.
141      *
142      * @param[seekBar] The SeekBar that reports a progress.
143      * @param[progress] The integer progress of the SeekBar within its min and max values.
144      * @return The progress in the range from 0F to 1F.
145      */
normalizeProgressnull146     private fun normalizeProgress(seekBar: SeekBar, progress: Int): Float {
147         if (seekBar.max == seekBar.min) {
148             return 1.0f
149         }
150         val range = seekBar.max - seekBar.min
151         return (progress - seekBar.min) / range.toFloat()
152     }
153 
154     /** onStopTrackingTouch event from the slider's [android.widget.SeekBar] */
onStopTrackingTouchnull155     fun onStopTrackingTouch(seekBar: SeekBar) {
156         if (isTracking) {
157             sliderEventProducer.onStopTracking(true)
158         }
159     }
160 
161     /** Programmatic changes have stopped */
onStoppedTrackingProgramnull162     private fun onStoppedTrackingProgram() {
163         if (isTracking) {
164             sliderEventProducer.onStopTracking(false)
165         }
166     }
167 
168     /**
169      * An external key was pressed (e.g., a volume key).
170      *
171      * This event is used to estimate the key-up event based on a running a timer as a waiting
172      * coroutine in the [pluginScope]. A key-up event in a slider corresponds to an onArrowUp event.
173      * Therefore, [onStoppedTrackingProgram] must be called after the timeout.
174      */
onKeyDownnull175     fun onKeyDown() {
176         if (!isTracking) return
177 
178         if (isKeyUpTimerWaiting) {
179             // Cancel the ongoing wait
180             keyUpJob?.cancel()
181         }
182         keyUpJob =
183             pluginScope?.launch {
184                 delay(KEY_UP_TIMEOUT)
185                 onStoppedTrackingProgram()
186             }
187     }
188 
189     companion object {
190         const val KEY_UP_TIMEOUT = 60L
191     }
192 }
193