1 /*
2  * Copyright (C) 2020 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.media.controls.ui.viewmodel
18 
19 import android.media.MediaMetadata
20 import android.media.session.MediaController
21 import android.media.session.PlaybackState
22 import android.os.SystemClock
23 import android.os.Trace
24 import android.view.GestureDetector
25 import android.view.MotionEvent
26 import android.view.View
27 import android.view.ViewConfiguration
28 import android.widget.SeekBar
29 import androidx.annotation.AnyThread
30 import androidx.annotation.VisibleForTesting
31 import androidx.annotation.WorkerThread
32 import androidx.core.view.GestureDetectorCompat
33 import androidx.lifecycle.LiveData
34 import androidx.lifecycle.MutableLiveData
35 import com.android.systemui.Flags
36 import com.android.systemui.classifier.Classifier.MEDIA_SEEKBAR
37 import com.android.systemui.dagger.qualifiers.Background
38 import com.android.systemui.plugins.FalsingManager
39 import com.android.systemui.statusbar.NotificationMediaManager
40 import com.android.systemui.util.concurrency.RepeatableExecutor
41 import javax.inject.Inject
42 import kotlin.math.abs
43 
44 private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L
45 private const val MIN_FLING_VELOCITY_SCALE_FACTOR = 10
46 
47 private const val TRACE_POSITION_NAME = "SeekBarPollingPosition"
48 
PlaybackStatenull49 private fun PlaybackState.isInMotion(): Boolean {
50     return this.state == PlaybackState.STATE_PLAYING ||
51         this.state == PlaybackState.STATE_FAST_FORWARDING ||
52         this.state == PlaybackState.STATE_REWINDING
53 }
54 
55 /**
56  * Gets the playback position while accounting for the time since the [PlaybackState] was last
57  * retrieved.
58  *
59  * This method closely follows the implementation of
60  * [MediaSessionRecord#getStateWithUpdatedPosition].
61  */
computePositionnull62 private fun PlaybackState.computePosition(duration: Long): Long {
63     var currentPosition = this.position
64     if (this.isInMotion()) {
65         val updateTime = this.getLastPositionUpdateTime()
66         val currentTime = SystemClock.elapsedRealtime()
67         if (updateTime > 0) {
68             var position =
69                 (this.playbackSpeed * (currentTime - updateTime)).toLong() + this.getPosition()
70             if (duration >= 0 && position > duration) {
71                 position = duration.toLong()
72             } else if (position < 0) {
73                 position = 0
74             }
75             currentPosition = position
76         }
77     }
78     return currentPosition
79 }
80 
81 /** ViewModel for seek bar in QS media player. */
82 class SeekBarViewModel
83 @Inject
84 constructor(
85     @Background private val bgExecutor: RepeatableExecutor,
86     private val falsingManager: FalsingManager,
87 ) {
88     private var _data =
89         Progress(
90             enabled = false,
91             seekAvailable = false,
92             playing = false,
93             scrubbing = false,
94             elapsedTime = null,
95             duration = 0,
96             listening = false
97         )
98         set(value) {
99             val enabledChanged = value.enabled != field.enabled
100             field = value
101             if (enabledChanged) {
102                 enabledChangeListener?.onEnabledChanged(value.enabled)
103             }
104             _progress.postValue(value)
105         }
106 
<lambda>null107     private val _progress = MutableLiveData<Progress>().apply { postValue(_data) }
108     val progress: LiveData<Progress>
109         get() = _progress
110 
111     private var controller: MediaController? = null
112         set(value) {
113             if (field?.sessionToken != value?.sessionToken) {
114                 field?.unregisterCallback(callback)
115                 value?.registerCallback(callback)
116                 field = value
117             }
118         }
119 
120     private var playbackState: PlaybackState? = null
121     private var callback =
122         object : MediaController.Callback() {
onPlaybackStateChangednull123             override fun onPlaybackStateChanged(state: PlaybackState?) {
124                 playbackState = state
125                 if (playbackState == null || PlaybackState.STATE_NONE.equals(playbackState)) {
126                     clearController()
127                 } else {
128                     checkIfPollingNeeded()
129                 }
130             }
131 
onSessionDestroyednull132             override fun onSessionDestroyed() {
133                 clearController()
134             }
135 
onMetadataChangednull136             override fun onMetadataChanged(metadata: MediaMetadata?) {
137                 if (!Flags.mediaControlsPostsOptimization()) return
138 
139                 val (enabled, duration) = getEnabledStateAndDuration(metadata)
140                 if (_data.duration != duration) {
141                     _data = _data.copy(enabled = enabled, duration = duration)
142                 }
143             }
144         }
145     private var cancel: Runnable? = null
146 
147     /** Indicates if the seek interaction is considered a false guesture. */
148     private var isFalseSeek = false
149 
150     /** Listening state (QS open or closed) is used to control polling of progress. */
151     var listening = true
152         set(value) =
<lambda>null153             bgExecutor.execute {
154                 if (field != value) {
155                     field = value
156                     checkIfPollingNeeded()
157                     _data = _data.copy(listening = value)
158                 }
159             }
160 
161     private var scrubbingChangeListener: ScrubbingChangeListener? = null
162     private var enabledChangeListener: EnabledChangeListener? = null
163 
164     /** Set to true when the user is touching the seek bar to change the position. */
165     private var scrubbing = false
166         set(value) {
167             if (field != value) {
168                 field = value
169                 checkIfPollingNeeded()
170                 scrubbingChangeListener?.onScrubbingChanged(value)
171                 _data = _data.copy(scrubbing = value)
172             }
173         }
174 
175     lateinit var logSeek: () -> Unit
176 
177     /** Event indicating that the user has started interacting with the seek bar. */
178     @AnyThread
onSeekStartingnull179     fun onSeekStarting() =
180         bgExecutor.execute {
181             scrubbing = true
182             isFalseSeek = false
183         }
184 
185     /**
186      * Event indicating that the user has moved the seek bar.
187      *
188      * @param position Current location in the track.
189      */
190     @AnyThread
onSeekProgressnull191     fun onSeekProgress(position: Long) =
192         bgExecutor.execute {
193             if (scrubbing) {
194                 // The user hasn't yet finished their touch gesture, so only update the data for
195                 // visual
196                 // feedback and don't update [controller] yet.
197                 _data = _data.copy(elapsedTime = position.toInt())
198             } else {
199                 // The seek progress came from an a11y action and we should immediately update to
200                 // the
201                 // new position. (a11y actions to change the seekbar position don't trigger
202                 // SeekBar.OnSeekBarChangeListener.onStartTrackingTouch or onStopTrackingTouch.)
203                 onSeek(position)
204             }
205         }
206 
207     /** Event indicating that the seek interaction is a false gesture and it should be ignored. */
208     @AnyThread
onSeekFalsenull209     fun onSeekFalse() =
210         bgExecutor.execute {
211             if (scrubbing) {
212                 isFalseSeek = true
213             }
214         }
215 
216     /**
217      * Handle request to change the current position in the media track.
218      *
219      * @param position Place to seek to in the track.
220      */
221     @AnyThread
onSeeknull222     fun onSeek(position: Long) =
223         bgExecutor.execute {
224             if (isFalseSeek) {
225                 scrubbing = false
226                 checkPlaybackPosition()
227             } else {
228                 logSeek()
229                 controller?.transportControls?.seekTo(position)
230                 // Invalidate the cached playbackState to avoid the thumb jumping back to the
231                 // previous
232                 // position.
233                 playbackState = null
234                 scrubbing = false
235             }
236         }
237 
238     /**
239      * Updates media information.
240      *
241      * This function makes a binder call, so it must happen on a worker thread.
242      *
243      * @param mediaController controller for media session
244      */
245     @WorkerThread
updateControllernull246     fun updateController(mediaController: MediaController?) {
247         controller = mediaController
248         playbackState = controller?.playbackState
249         val (enabled, duration) = getEnabledStateAndDuration(controller?.metadata)
250         val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
251         val position = playbackState?.position?.toInt()
252         val playing =
253             NotificationMediaManager.isPlayingState(
254                 playbackState?.state ?: PlaybackState.STATE_NONE
255             )
256         _data = Progress(enabled, seekAvailable, playing, scrubbing, position, duration, listening)
257         checkIfPollingNeeded()
258     }
259 
260     /**
261      * Set the progress to a fixed percentage value that cannot be changed by the user.
262      *
263      * @param percent value between 0 and 1
264      */
updateStaticProgressnull265     fun updateStaticProgress(percent: Double) {
266         val position = (percent * 100).toInt()
267         _data =
268             Progress(
269                 enabled = true,
270                 seekAvailable = false,
271                 playing = false,
272                 scrubbing = false,
273                 elapsedTime = position,
274                 duration = 100,
275                 listening = false,
276             )
277     }
278 
279     /**
280      * Puts the seek bar into a resumption state.
281      *
282      * This should be called when the media session behind the controller has been destroyed.
283      */
284     @AnyThread
clearControllernull285     fun clearController() =
286         bgExecutor.execute {
287             controller = null
288             playbackState = null
289             cancel?.run()
290             cancel = null
291             _data = _data.copy(enabled = false)
292         }
293 
294     /** Call to clean up any resources. */
295     @AnyThread
onDestroynull296     fun onDestroy() =
297         bgExecutor.execute {
298             controller = null
299             playbackState = null
300             cancel?.run()
301             cancel = null
302             scrubbingChangeListener = null
303             enabledChangeListener = null
304         }
305 
306     @WorkerThread
checkPlaybackPositionnull307     private fun checkPlaybackPosition() {
308         val duration = _data.duration ?: -1
309         val currentPosition = playbackState?.computePosition(duration.toLong())?.toInt()
310         if (currentPosition != null && _data.elapsedTime != currentPosition) {
311             _data = _data.copy(elapsedTime = currentPosition)
312         }
313     }
314 
315     @WorkerThread
checkIfPollingNeedednull316     private fun checkIfPollingNeeded() {
317         val needed = listening && !scrubbing && playbackState?.isInMotion() ?: false
318         val traceCookie = controller?.sessionToken.hashCode()
319         if (needed) {
320             if (cancel == null) {
321                 Trace.beginAsyncSection(TRACE_POSITION_NAME, traceCookie)
322                 val cancelPolling =
323                     bgExecutor.executeRepeatedly(
324                         this::checkPlaybackPosition,
325                         0L,
326                         POSITION_UPDATE_INTERVAL_MILLIS
327                     )
328                 cancel = Runnable {
329                     cancelPolling.run()
330                     Trace.endAsyncSection(TRACE_POSITION_NAME, traceCookie)
331                 }
332             }
333         } else {
334             cancel?.run()
335             cancel = null
336         }
337     }
338 
339     /** Gets a listener to attach to the seek bar to handle seeking. */
340     val seekBarListener: SeekBar.OnSeekBarChangeListener
341         get() {
342             return SeekBarChangeListener(this, falsingManager)
343         }
344 
345     /** first and last motion events of seekbar grab. */
346     @VisibleForTesting var firstMotionEvent: MotionEvent? = null
347     @VisibleForTesting var lastMotionEvent: MotionEvent? = null
348 
349     /** Attach touch handlers to the seek bar view. */
attachTouchHandlersnull350     fun attachTouchHandlers(bar: SeekBar) {
351         bar.setOnSeekBarChangeListener(seekBarListener)
352         bar.setOnTouchListener(SeekBarTouchListener(this, bar))
353     }
354 
setScrubbingChangeListenernull355     fun setScrubbingChangeListener(listener: ScrubbingChangeListener) {
356         scrubbingChangeListener = listener
357     }
358 
removeScrubbingChangeListenernull359     fun removeScrubbingChangeListener(listener: ScrubbingChangeListener) {
360         if (listener == scrubbingChangeListener) {
361             scrubbingChangeListener = null
362         }
363     }
364 
setEnabledChangeListenernull365     fun setEnabledChangeListener(listener: EnabledChangeListener) {
366         enabledChangeListener = listener
367     }
368 
removeEnabledChangeListenernull369     fun removeEnabledChangeListener(listener: EnabledChangeListener) {
370         if (listener == enabledChangeListener) {
371             enabledChangeListener = null
372         }
373     }
374 
375     /** returns a pair of whether seekbar is enabled and the duration of media. */
getEnabledStateAndDurationnull376     private fun getEnabledStateAndDuration(metadata: MediaMetadata?): Pair<Boolean, Int> {
377         val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0
378         val enabled =
379             !(playbackState == null ||
380                 playbackState?.state == PlaybackState.STATE_NONE ||
381                 (duration <= 0))
382         return Pair(enabled, duration)
383     }
384 
385     /**
386      * This method specifies if user made a bad seekbar grab or not. If the vertical distance from
387      * first touch on seekbar is more than the horizontal distance, this means that the seekbar grab
388      * is more vertical and should be rejected. Seekbar accepts horizontal grabs only.
389      *
390      * Single tap has the same first and last motion event, it is counted as a valid grab.
391      *
392      * @return whether the touch on seekbar is valid.
393      */
isValidSeekbarGrabnull394     private fun isValidSeekbarGrab(): Boolean {
395         if (firstMotionEvent == null || lastMotionEvent == null) {
396             return true
397         }
398         return abs(firstMotionEvent!!.x - lastMotionEvent!!.x) >=
399             abs(firstMotionEvent!!.y - lastMotionEvent!!.y)
400     }
401 
402     /** Listener interface to be notified when the user starts or stops scrubbing. */
403     interface ScrubbingChangeListener {
onScrubbingChangednull404         fun onScrubbingChanged(scrubbing: Boolean)
405     }
406 
407     /** Listener interface to be notified when the seekbar's enabled status changes. */
408     interface EnabledChangeListener {
409         fun onEnabledChanged(enabled: Boolean)
410     }
411 
412     private class SeekBarChangeListener(
413         val viewModel: SeekBarViewModel,
414         val falsingManager: FalsingManager,
415     ) : SeekBar.OnSeekBarChangeListener {
onProgressChangednull416         override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
417             if (fromUser) {
418                 viewModel.onSeekProgress(progress.toLong())
419             }
420         }
421 
onStartTrackingTouchnull422         override fun onStartTrackingTouch(bar: SeekBar) {
423             viewModel.onSeekStarting()
424         }
425 
onStopTrackingTouchnull426         override fun onStopTrackingTouch(bar: SeekBar) {
427             if (!viewModel.isValidSeekbarGrab() || falsingManager.isFalseTouch(MEDIA_SEEKBAR)) {
428                 viewModel.onSeekFalse()
429             }
430             viewModel.onSeek(bar.progress.toLong())
431         }
432     }
433 
434     /**
435      * Responsible for intercepting touch events before they reach the seek bar.
436      *
437      * This reduces the gestures seen by the seek bar so that users don't accidentially seek when
438      * they intend to scroll the carousel.
439      */
440     private class SeekBarTouchListener(
441         private val viewModel: SeekBarViewModel,
442         private val bar: SeekBar,
443     ) : View.OnTouchListener, GestureDetector.OnGestureListener {
444 
445         // Gesture detector helps decide which touch events to intercept.
446         private val detector = GestureDetectorCompat(bar.context, this)
447         // Velocity threshold used to decide when a fling is considered a false gesture.
448         private val flingVelocity: Int =
<lambda>null449             ViewConfiguration.get(bar.context).run {
450                 getScaledMinimumFlingVelocity() * MIN_FLING_VELOCITY_SCALE_FACTOR
451             }
452         // Indicates if the gesture should go to the seek bar or if it should be intercepted.
453         private var shouldGoToSeekBar = false
454 
455         /**
456          * Decide which touch events to intercept before they reach the seek bar.
457          *
458          * Based on the gesture detected, we decide whether we want the event to reach the seek bar.
459          * If we want the seek bar to see the event, then we return false so that the event isn't
460          * handled here and it will be passed along. If, however, we don't want the seek bar to see
461          * the event, then return true so that the event is handled here.
462          *
463          * When the seek bar is contained in the carousel, the carousel still has the ability to
464          * intercept the touch event. So, even though we may handle the event here, the carousel can
465          * still intercept the event. This way, gestures that we consider falses on the seek bar can
466          * still be used by the carousel for paging.
467          *
468          * Returns true for events that we don't want dispatched to the seek bar.
469          */
onTouchnull470         override fun onTouch(view: View, event: MotionEvent): Boolean {
471             if (view != bar) {
472                 return false
473             }
474             detector.onTouchEvent(event)
475             // Store the last motion event done on seekbar.
476             viewModel.lastMotionEvent = event.copy()
477             return !shouldGoToSeekBar
478         }
479 
480         /**
481          * Handle down events that press down on the thumb.
482          *
483          * On the down action, determine a target box around the thumb to know when a scroll gesture
484          * starts by clicking on the thumb. The target box will be used by subsequent onScroll
485          * events.
486          *
487          * Returns true when the down event hits within the target box of the thumb.
488          */
onDownnull489         override fun onDown(event: MotionEvent): Boolean {
490             val padL = bar.paddingLeft
491             val padR = bar.paddingRight
492             // Compute the X location of the thumb as a function of the seek bar progress.
493             // TODO: account for thumb offset
494             val progress = bar.getProgress()
495             val range = bar.max - bar.min
496             val widthFraction =
497                 if (range > 0) {
498                     (progress - bar.min).toDouble() / range
499                 } else {
500                     0.0
501                 }
502             val availableWidth = bar.width - padL - padR
503             val thumbX =
504                 if (bar.isLayoutRtl()) {
505                     padL + availableWidth * (1 - widthFraction)
506                 } else {
507                     padL + availableWidth * widthFraction
508                 }
509             // Set the min, max boundaries of the thumb box.
510             // I'm cheating by using the height of the seek bar as the width of the box.
511             val halfHeight: Int = bar.height / 2
512             val targetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
513             val targetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
514             // If the x position of the down event is within the box, then request that the parent
515             // not intercept the event.
516             val x = Math.round(event.x)
517             shouldGoToSeekBar = x >= targetBoxMinX && x <= targetBoxMaxX
518             if (shouldGoToSeekBar) {
519                 bar.parent?.requestDisallowInterceptTouchEvent(true)
520             }
521             // Store the first motion event done on seekbar.
522             viewModel.firstMotionEvent = event.copy()
523             return shouldGoToSeekBar
524         }
525 
526         /**
527          * Always handle single tap up.
528          *
529          * This enables the user to single tap anywhere on the seek bar to seek to that position.
530          */
onSingleTapUpnull531         override fun onSingleTapUp(event: MotionEvent): Boolean {
532             shouldGoToSeekBar = true
533             return true
534         }
535 
536         /**
537          * Handle scroll events when the down event is on the thumb.
538          *
539          * Returns true when the down event of the scroll hits within the target box of the thumb.
540          */
onScrollnull541         override fun onScroll(
542             eventStart: MotionEvent?,
543             event: MotionEvent,
544             distanceX: Float,
545             distanceY: Float
546         ): Boolean {
547             return shouldGoToSeekBar
548         }
549 
550         /**
551          * Handle fling events when the down event is on the thumb.
552          *
553          * Gestures that include a fling are considered a false gesture on the seek bar.
554          */
onFlingnull555         override fun onFling(
556             eventStart: MotionEvent?,
557             event: MotionEvent,
558             velocityX: Float,
559             velocityY: Float
560         ): Boolean {
561             if (Math.abs(velocityX) > flingVelocity || Math.abs(velocityY) > flingVelocity) {
562                 viewModel.onSeekFalse()
563             }
564             return shouldGoToSeekBar
565         }
566 
onShowPressnull567         override fun onShowPress(event: MotionEvent) {}
568 
onLongPressnull569         override fun onLongPress(event: MotionEvent) {}
570     }
571 
572     /** State seen by seek bar UI. */
573     data class Progress(
574         val enabled: Boolean,
575         val seekAvailable: Boolean,
576         /** whether playback state is not paused or connecting */
577         val playing: Boolean,
578         val scrubbing: Boolean,
579         val elapsedTime: Int?,
580         val duration: Int,
581         /** whether seekBar is listening to progress updates */
582         val listening: Boolean,
583     )
584 }
585