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 androidx.annotation.VisibleForTesting
20 import kotlin.math.abs
21 import kotlinx.coroutines.CoroutineScope
22 import kotlinx.coroutines.Job
23 import kotlinx.coroutines.delay
24 import kotlinx.coroutines.isActive
25 import kotlinx.coroutines.launch
26 
27 /**
28  * Slider tracker attached to a slider.
29  *
30  * The tracker runs a state machine to execute actions on touch-based events typical of a general
31  * slider (including a [android.widget.SeekBar]). Coroutines responsible for running the state
32  * machine, collecting slider events and maintaining waiting states are run on the provided
33  * [CoroutineScope].
34  *
35  * @param[sliderStateListener] Listener of the slider state.
36  * @param[sliderEventProducer] Producer of slider events arising from the slider.
37  * @param[trackerScope] [CoroutineScope] used to launch coroutines for the collection of slider
38  *   events and the launch of timer jobs.
39  * @property[config] Configuration parameters of the slider tracker.
40  */
41 class SliderStateTracker(
42     sliderStateListener: SliderStateListener,
43     sliderEventProducer: SliderEventProducer,
44     trackerScope: CoroutineScope,
45     private val config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
46 ) : SliderTracker(trackerScope, sliderStateListener, sliderEventProducer) {
47 
48     // History of the latest progress collected from slider events
49     private var latestProgress = 0f
50     // Timer job for the wait state
51     private var timerJob: Job? = null
52     // Indicator that there is waiting job active
53     var isWaiting = false
54         private set
55         get() = timerJob != null && timerJob?.isActive == true
56 
iterateStatenull57     override suspend fun iterateState(event: SliderEvent) {
58         when (currentState) {
59             SliderState.IDLE -> handleIdle(event.type, event.currentProgress)
60             SliderState.WAIT -> handleWait(event.type, event.currentProgress)
61             SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> handleAcquired(event.type)
62             SliderState.DRAG_HANDLE_DRAGGING -> handleDragging(event.type, event.currentProgress)
63             SliderState.DRAG_HANDLE_REACHED_BOOKEND ->
64                 handleReachedBookend(event.type, event.currentProgress)
65             SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> setState(SliderState.IDLE)
66             SliderState.JUMP_TRACK_LOCATION_SELECTED -> handleJumpToTrack(event.type)
67             SliderState.JUMP_BOOKEND_SELECTED -> handleJumpToBookend(event.type)
68             SliderState.ARROW_HANDLE_MOVED_ONCE -> handleArrowOnce(event.type)
69             SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY ->
70                 handleArrowContinuous(event.type, event.currentProgress)
71             SliderState.ARROW_HANDLE_REACHED_BOOKEND -> handleArrowBookend()
72         }
73         latestProgress = event.currentProgress
74     }
75 
handleIdlenull76     private fun handleIdle(newEventType: SliderEventType, currentProgress: Float) {
77         if (newEventType == SliderEventType.STARTED_TRACKING_TOUCH) {
78             timerJob = launchTimer()
79             // The WAIT state will wait for the timer to complete or a slider progress to occur.
80             // This will disambiguate between an imprecise touch that acquires the slider handle,
81             // and a select and jump operation in the slider track.
82             setState(SliderState.WAIT)
83         } else if (newEventType == SliderEventType.STARTED_TRACKING_PROGRAM) {
84             val state =
85                 if (bookendReached(currentProgress)) SliderState.ARROW_HANDLE_REACHED_BOOKEND
86                 else SliderState.ARROW_HANDLE_MOVED_ONCE
87             setState(state)
88         }
89     }
90 
launchTimernull91     private fun launchTimer() =
92         scope.launch {
93             delay(config.waitTimeMillis)
94             if (isActive && currentState == SliderState.WAIT) {
95                 setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
96                 // This transitory state must also trigger the corresponding action
97                 executeOnState(currentState)
98             }
99         }
100 
handleWaitnull101     private fun handleWait(newEventType: SliderEventType, currentProgress: Float) {
102         // The timer may have completed and may have already modified the state
103         if (currentState != SliderState.WAIT) return
104 
105         // The timer is still running but the state may be modified by the progress change
106         val deltaProgressIsJump = deltaProgressIsAboveThreshold(currentProgress)
107         if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
108             if (bookendReached(currentProgress)) {
109                 setState(SliderState.JUMP_BOOKEND_SELECTED)
110             } else if (deltaProgressIsJump) {
111                 setState(SliderState.JUMP_TRACK_LOCATION_SELECTED)
112             } else {
113                 setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
114             }
115         } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
116             setState(SliderState.IDLE)
117         }
118 
119         // If the state changed, the timer does not need to complete. No further synchronization
120         // will be required onwards until WAIT is reached again.
121         if (currentState != SliderState.WAIT) {
122             timerJob?.cancel()
123             timerJob = null
124         }
125     }
126 
handleAcquirednull127     private fun handleAcquired(newEventType: SliderEventType) {
128         if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
129             setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
130         } else if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
131             setState(SliderState.DRAG_HANDLE_DRAGGING)
132         }
133     }
134 
handleDraggingnull135     private fun handleDragging(newEventType: SliderEventType, currentProgress: Float) {
136         if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
137             setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
138         } else if (
139             newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER &&
140                 bookendReached(currentProgress)
141         ) {
142             setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
143         }
144     }
145 
handleReachedBookendnull146     private fun handleReachedBookend(newEventType: SliderEventType, currentProgress: Float) {
147         if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
148             if (!bookendReached(currentProgress)) {
149                 setState(SliderState.DRAG_HANDLE_DRAGGING)
150             }
151         } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
152             setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
153         }
154     }
155 
handleJumpToTracknull156     private fun handleJumpToTrack(newEventType: SliderEventType) {
157         when (newEventType) {
158             SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING)
159             SliderEventType.STOPPED_TRACKING_TOUCH ->
160                 setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
161             else -> {}
162         }
163     }
164 
handleJumpToBookendnull165     private fun handleJumpToBookend(newEventType: SliderEventType) {
166         when (newEventType) {
167             SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING)
168             SliderEventType.STOPPED_TRACKING_TOUCH ->
169                 setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
170             else -> {}
171         }
172     }
173 
executeOnStatenull174     override fun executeOnState(currentState: SliderState) {
175         when (currentState) {
176             SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> sliderListener.onHandleAcquiredByTouch()
177             SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> {
178                 sliderListener.onHandleReleasedFromTouch()
179                 // This transitory state must also reset the state machine
180                 resetState()
181             }
182             SliderState.DRAG_HANDLE_DRAGGING -> sliderListener.onProgress(latestProgress)
183             SliderState.DRAG_HANDLE_REACHED_BOOKEND -> executeOnBookend()
184             SliderState.JUMP_TRACK_LOCATION_SELECTED ->
185                 sliderListener.onProgressJump(latestProgress)
186             SliderState.ARROW_HANDLE_MOVED_ONCE -> sliderListener.onSelectAndArrow(latestProgress)
187             SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY -> sliderListener.onProgress(latestProgress)
188             SliderState.ARROW_HANDLE_REACHED_BOOKEND -> {
189                 executeOnBookend()
190                 // This transitory execution must also reset the state
191                 resetState()
192             }
193             else -> {}
194         }
195     }
196 
executeOnBookendnull197     private fun executeOnBookend() {
198         if (latestProgress >= config.upperBookendThreshold) sliderListener.onUpperBookend()
199         else sliderListener.onLowerBookend()
200     }
201 
resetStatenull202     override fun resetState() {
203         timerJob?.cancel()
204         timerJob = null
205         super.resetState()
206     }
207 
deltaProgressIsAboveThresholdnull208     private fun deltaProgressIsAboveThreshold(
209         currentProgress: Float,
210         epsilon: Float = 0.00001f,
211     ): Boolean {
212         val delta = abs(currentProgress - latestProgress)
213         return delta > config.jumpThreshold - epsilon
214     }
215 
bookendReachednull216     private fun bookendReached(currentProgress: Float): Boolean {
217         return currentProgress >= config.upperBookendThreshold ||
218             currentProgress <= config.lowerBookendThreshold
219     }
220 
handleArrowOncenull221     private fun handleArrowOnce(newEventType: SliderEventType) {
222         val nextState =
223             when (newEventType) {
224                 SliderEventType.STARTED_TRACKING_TOUCH -> {
225                     // Launching the timer and going to WAIT
226                     timerJob = launchTimer()
227                     SliderState.WAIT
228                 }
229                 SliderEventType.PROGRESS_CHANGE_BY_PROGRAM ->
230                     SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY
231                 SliderEventType.STOPPED_TRACKING_PROGRAM -> SliderState.IDLE
232                 else -> SliderState.ARROW_HANDLE_MOVED_ONCE
233             }
234         setState(nextState)
235     }
236 
handleArrowContinuousnull237     private fun handleArrowContinuous(newEventType: SliderEventType, currentProgress: Float) {
238         val reachedBookend = bookendReached(currentProgress)
239         val nextState =
240             when (newEventType) {
241                 SliderEventType.STOPPED_TRACKING_PROGRAM -> SliderState.IDLE
242                 SliderEventType.STARTED_TRACKING_TOUCH -> {
243                     // Launching the timer and going to WAIT
244                     timerJob = launchTimer()
245                     SliderState.WAIT
246                 }
247                 SliderEventType.PROGRESS_CHANGE_BY_PROGRAM -> {
248                     if (reachedBookend) SliderState.ARROW_HANDLE_REACHED_BOOKEND
249                     else SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY
250                 }
251                 else -> SliderState.ARROW_HANDLE_MOVES_CONTINUOUSLY
252             }
253         setState(nextState)
254     }
255 
handleArrowBookendnull256     private fun handleArrowBookend() = setState(SliderState.IDLE)
257 
258     @VisibleForTesting
259     fun setState(state: SliderState) {
260         currentState = state
261     }
262 }
263