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.view
18 
19 import android.graphics.Outline
20 import android.util.MathUtils
21 import android.view.GestureDetector
22 import android.view.MotionEvent
23 import android.view.View
24 import android.view.ViewGroup
25 import android.view.ViewOutlineProvider
26 import androidx.core.view.GestureDetectorCompat
27 import androidx.dynamicanimation.animation.FloatPropertyCompat
28 import androidx.dynamicanimation.animation.SpringForce
29 import com.android.app.tracing.TraceStateLogger
30 import com.android.internal.annotations.VisibleForTesting
31 import com.android.settingslib.Utils
32 import com.android.systemui.Gefingerpoken
33 import com.android.systemui.classifier.Classifier.NOTIFICATION_DISMISS
34 import com.android.systemui.media.controls.util.MediaUiEventLogger
35 import com.android.systemui.plugins.FalsingManager
36 import com.android.systemui.qs.PageIndicator
37 import com.android.systemui.res.R
38 import com.android.systemui.util.animation.TransitionLayout
39 import com.android.systemui.util.concurrency.DelayableExecutor
40 import com.android.wm.shell.shared.animation.PhysicsAnimator
41 
42 private const val FLING_SLOP = 1000000
43 private const val DISMISS_DELAY = 100L
44 private const val SCROLL_DELAY = 100L
45 private const val RUBBERBAND_FACTOR = 0.2f
46 private const val SETTINGS_BUTTON_TRANSLATION_FRACTION = 0.3f
47 private const val TAG = "MediaCarouselScrollHandler"
48 
49 /**
50  * Default spring configuration to use for animations where stiffness and/or damping ratio were not
51  * provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig].
52  */
53 private val translationConfig =
54     PhysicsAnimator.SpringConfig(SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_LOW_BOUNCY)
55 
56 /** A controller class for the media scrollview, responsible for touch handling */
57 class MediaCarouselScrollHandler(
58     private val scrollView: MediaScrollView,
59     private val pageIndicator: PageIndicator,
60     private val mainExecutor: DelayableExecutor,
61     val dismissCallback: () -> Unit,
62     private var translationChangedListener: () -> Unit,
63     private var seekBarUpdateListener: (visibleToUser: Boolean) -> Unit,
64     private val closeGuts: (immediate: Boolean) -> Unit,
65     private val falsingManager: FalsingManager,
66     private val logSmartspaceImpression: (Boolean) -> Unit,
67     private val logger: MediaUiEventLogger
68 ) {
69     /** Trace state logger for media carousel visibility */
70     private val visibleStateLogger = TraceStateLogger("$TAG#visibleToUser")
71 
72     /** Is the view in RTL */
73     val isRtl: Boolean
74         get() = scrollView.isLayoutRtl
75 
76     /** Do we need falsing protection? */
77     var falsingProtectionNeeded: Boolean = false
78 
79     /** The width of the carousel */
80     private var carouselWidth: Int = 0
81 
82     /** The height of the carousel */
83     private var carouselHeight: Int = 0
84 
85     /** How much are we scrolled into the current media? */
86     private var cornerRadius: Int = 0
87 
88     /** The content where the players are added */
89     private var mediaContent: ViewGroup
90 
91     /** The gesture detector to detect touch gestures */
92     private val gestureDetector: GestureDetectorCompat
93 
94     /** The settings button view */
95     private lateinit var settingsButton: View
96 
97     /** What's the currently visible player index? */
98     var visibleMediaIndex: Int = 0
99         private set
100 
101     /** How much are we scrolled into the current media? */
102     private var scrollIntoCurrentMedia: Int = 0
103 
104     /** how much is the content translated in X */
105     var contentTranslation = 0.0f
106         private set(value) {
107             field = value
108             mediaContent.translationX = value
109             updateSettingsPresentation()
110             translationChangedListener.invoke()
111             updateClipToOutline()
112         }
113 
114     /** The width of a player including padding */
115     var playerWidthPlusPadding: Int = 0
116         set(value) {
117             field = value
118             // The player width has changed, let's update the scroll position to make sure
119             // it's still at the same place
120             var newRelativeScroll = visibleMediaIndex * playerWidthPlusPadding
121             if (scrollIntoCurrentMedia > playerWidthPlusPadding) {
122                 newRelativeScroll +=
123                     playerWidthPlusPadding - (scrollIntoCurrentMedia - playerWidthPlusPadding)
124             } else {
125                 newRelativeScroll += scrollIntoCurrentMedia
126             }
127             scrollView.relativeScrollX = newRelativeScroll
128         }
129 
130     /** Does the dismiss currently show the setting cog? */
131     var showsSettingsButton: Boolean = false
132 
133     /** A utility to detect gestures, used in the touch listener */
134     private val gestureListener =
135         object : GestureDetector.SimpleOnGestureListener() {
onFlingnull136             override fun onFling(
137                 eStart: MotionEvent?,
138                 eCurrent: MotionEvent,
139                 vX: Float,
140                 vY: Float
141             ) = onFling(vX, vY)
142 
143             override fun onScroll(
144                 down: MotionEvent?,
145                 lastMotion: MotionEvent,
146                 distanceX: Float,
147                 distanceY: Float
148             ) = onScroll(down!!, lastMotion, distanceX)
149 
150             override fun onDown(e: MotionEvent): Boolean {
151                 return false
152             }
153         }
154 
155     /** The touch listener for the scroll view */
156     @VisibleForTesting
157     val touchListener =
158         object : Gefingerpoken {
onTouchEventnull159             override fun onTouchEvent(motionEvent: MotionEvent?) = onTouch(motionEvent!!)
160             override fun onInterceptTouchEvent(ev: MotionEvent?) = onInterceptTouch(ev!!)
161         }
162 
163     /** A listener that is invoked when the scrolling changes to update player visibilities */
164     private val scrollChangedListener =
165         object : View.OnScrollChangeListener {
166             override fun onScrollChange(
167                 v: View?,
168                 scrollX: Int,
169                 scrollY: Int,
170                 oldScrollX: Int,
171                 oldScrollY: Int
172             ) {
173                 if (playerWidthPlusPadding == 0) {
174                     return
175                 }
176 
177                 val relativeScrollX = scrollView.relativeScrollX
178                 onMediaScrollingChanged(
179                     relativeScrollX / playerWidthPlusPadding,
180                     relativeScrollX % playerWidthPlusPadding
181                 )
182             }
183         }
184 
185     /** Whether the media card is visible to user if any */
186     var visibleToUser: Boolean = false
187         set(value) {
188             if (field != value) {
189                 field = value
190                 seekBarUpdateListener.invoke(field)
191                 visibleStateLogger.log("$visibleToUser")
192             }
193         }
194 
195     /** Whether the quick setting is expanded or not */
196     var qsExpanded: Boolean = false
197 
198     init {
199         gestureDetector = GestureDetectorCompat(scrollView.context, gestureListener)
200         scrollView.touchListener = touchListener
201         scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER)
202         mediaContent = scrollView.contentContainer
203         scrollView.setOnScrollChangeListener(scrollChangedListener)
204         scrollView.outlineProvider =
205             object : ViewOutlineProvider() {
getOutlinenull206                 override fun getOutline(view: View?, outline: Outline?) {
207                     outline?.setRoundRect(
208                         0,
209                         0,
210                         carouselWidth,
211                         carouselHeight,
212                         cornerRadius.toFloat()
213                     )
214                 }
215             }
216     }
217 
onSettingsButtonUpdatednull218     fun onSettingsButtonUpdated(button: View) {
219         settingsButton = button
220         // We don't have a context to resolve, lets use the settingsbuttons one since that is
221         // reinflated appropriately
222         cornerRadius =
223             settingsButton.resources.getDimensionPixelSize(
224                 Utils.getThemeAttr(settingsButton.context, android.R.attr.dialogCornerRadius)
225             )
226         updateSettingsPresentation()
227         scrollView.invalidateOutline()
228     }
229 
updateSettingsPresentationnull230     private fun updateSettingsPresentation() {
231         if (showsSettingsButton && settingsButton.width > 0) {
232             val settingsOffset =
233                 MathUtils.map(
234                     0.0f,
235                     getMaxTranslation().toFloat(),
236                     0.0f,
237                     1.0f,
238                     Math.abs(contentTranslation)
239                 )
240             val settingsTranslation =
241                 (1.0f - settingsOffset) *
242                     -settingsButton.width *
243                     SETTINGS_BUTTON_TRANSLATION_FRACTION
244             val newTranslationX =
245                 if (isRtl) {
246                     // In RTL, the 0-placement is on the right side of the view, not the left...
247                     if (contentTranslation > 0) {
248                         -(scrollView.width - settingsTranslation - settingsButton.width)
249                     } else {
250                         -settingsTranslation
251                     }
252                 } else {
253                     if (contentTranslation > 0) {
254                         settingsTranslation
255                     } else {
256                         scrollView.width - settingsTranslation - settingsButton.width
257                     }
258                 }
259             val rotation = (1.0f - settingsOffset) * 50
260             settingsButton.rotation = rotation * -Math.signum(contentTranslation)
261             val alpha = MathUtils.saturate(MathUtils.map(0.5f, 1.0f, 0.0f, 1.0f, settingsOffset))
262             settingsButton.alpha = alpha
263             settingsButton.visibility = if (alpha != 0.0f) View.VISIBLE else View.INVISIBLE
264             settingsButton.translationX = newTranslationX
265             settingsButton.translationY = (scrollView.height - settingsButton.height) / 2.0f
266         } else {
267             settingsButton.visibility = View.INVISIBLE
268         }
269     }
270 
onTouchnull271     private fun onTouch(motionEvent: MotionEvent): Boolean {
272         val isUp = motionEvent.action == MotionEvent.ACTION_UP
273         if (gestureDetector.onTouchEvent(motionEvent)) {
274             if (isUp) {
275                 // If this is an up and we're flinging, we don't want to have this touch reach
276                 // the view, otherwise that would scroll, while we are trying to snap to the
277                 // new page. Let's dispatch a cancel instead.
278                 scrollView.cancelCurrentScroll()
279                 return true
280             } else {
281                 // Pass touches to the scrollView
282                 return false
283             }
284         }
285         if (motionEvent.action == MotionEvent.ACTION_MOVE) {
286             // cancel on going animation if there is any.
287             PhysicsAnimator.getInstance(this).cancel()
288         } else if (isUp || motionEvent.action == MotionEvent.ACTION_CANCEL) {
289             // It's an up and the fling didn't take it above
290             val relativePos = scrollView.relativeScrollX % playerWidthPlusPadding
291             val scrollXAmount: Int =
292                 if (relativePos > playerWidthPlusPadding / 2) {
293                     playerWidthPlusPadding - relativePos
294                 } else {
295                     -1 * relativePos
296                 }
297             if (scrollXAmount != 0) {
298                 val dx = if (isRtl) -scrollXAmount else scrollXAmount
299                 val newScrollX = scrollView.scrollX + dx
300                 // Delay the scrolling since scrollView calls springback which cancels
301                 // the animation again..
302                 mainExecutor.execute { scrollView.smoothScrollTo(newScrollX, scrollView.scrollY) }
303             }
304             val currentTranslation = scrollView.getContentTranslation()
305             if (currentTranslation != 0.0f) {
306                 // We started a Swipe but didn't end up with a fling. Let's either go to the
307                 // dismissed position or go back.
308                 val springBack =
309                     Math.abs(currentTranslation) < getMaxTranslation() / 2 || isFalseTouch()
310                 val newTranslation: Float
311                 if (springBack) {
312                     newTranslation = 0.0f
313                 } else {
314                     newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
315                     if (!showsSettingsButton) {
316                         // Delay the dismiss a bit to avoid too much overlap. Waiting until the
317                         // animation has finished also feels a bit too slow here.
318                         mainExecutor.executeDelayed({ dismissCallback.invoke() }, DISMISS_DELAY)
319                     }
320                 }
321                 PhysicsAnimator.getInstance(this)
322                     .spring(
323                         CONTENT_TRANSLATION,
324                         newTranslation,
325                         startVelocity = 0.0f,
326                         config = translationConfig
327                     )
328                     .start()
329                 scrollView.animationTargetX = newTranslation
330             }
331         }
332         // Always pass touches to the scrollView
333         return false
334     }
335 
isFalseTouchnull336     private fun isFalseTouch() =
337         falsingProtectionNeeded && falsingManager.isFalseTouch(NOTIFICATION_DISMISS)
338 
339     private fun getMaxTranslation() =
340         if (showsSettingsButton) {
341             settingsButton.width
342         } else {
343             playerWidthPlusPadding
344         }
345 
onInterceptTouchnull346     private fun onInterceptTouch(motionEvent: MotionEvent): Boolean {
347         return gestureDetector.onTouchEvent(motionEvent)
348     }
349 
onScrollnull350     fun onScroll(down: MotionEvent, lastMotion: MotionEvent, distanceX: Float): Boolean {
351         val totalX = lastMotion.x - down.x
352         val currentTranslation = scrollView.getContentTranslation()
353         if (currentTranslation != 0.0f || !scrollView.canScrollHorizontally((-totalX).toInt())) {
354             var newTranslation = currentTranslation - distanceX
355             val absTranslation = Math.abs(newTranslation)
356             if (absTranslation > getMaxTranslation()) {
357                 // Rubberband all translation above the maximum
358                 if (Math.signum(distanceX) != Math.signum(currentTranslation)) {
359                     // The movement is in the same direction as our translation,
360                     // Let's rubberband it.
361                     if (Math.abs(currentTranslation) > getMaxTranslation()) {
362                         // we were already overshooting before. Let's add the distance
363                         // fully rubberbanded.
364                         newTranslation = currentTranslation - distanceX * RUBBERBAND_FACTOR
365                     } else {
366                         // We just crossed the boundary, let's rubberband it all
367                         newTranslation =
368                             Math.signum(newTranslation) *
369                                 (getMaxTranslation() +
370                                     (absTranslation - getMaxTranslation()) * RUBBERBAND_FACTOR)
371                     }
372                 } // Otherwise we don't have do do anything, and will remove the unrubberbanded
373                 // translation
374             }
375             if (
376                 Math.signum(newTranslation) != Math.signum(currentTranslation) &&
377                     currentTranslation != 0.0f
378             ) {
379                 // We crossed the 0.0 threshold of the translation. Let's see if we're allowed
380                 // to scroll into the new direction
381                 if (scrollView.canScrollHorizontally(-newTranslation.toInt())) {
382                     // We can actually scroll in the direction where we want to translate,
383                     // Let's make sure to stop at 0
384                     newTranslation = 0.0f
385                 }
386             }
387             val physicsAnimator = PhysicsAnimator.getInstance(this)
388             if (physicsAnimator.isRunning()) {
389                 physicsAnimator
390                     .spring(
391                         CONTENT_TRANSLATION,
392                         newTranslation,
393                         startVelocity = 0.0f,
394                         config = translationConfig
395                     )
396                     .start()
397             } else {
398                 contentTranslation = newTranslation
399             }
400             scrollView.animationTargetX = newTranslation
401             return true
402         }
403         return false
404     }
405 
onFlingnull406     private fun onFling(vX: Float, vY: Float): Boolean {
407         if (vX * vX < 0.5 * vY * vY) {
408             return false
409         }
410         if (vX * vX < FLING_SLOP) {
411             return false
412         }
413         val currentTranslation = scrollView.getContentTranslation()
414         if (currentTranslation != 0.0f) {
415             // We're translated and flung. Let's see if the fling is in the same direction
416             val newTranslation: Float
417             if (Math.signum(vX) != Math.signum(currentTranslation) || isFalseTouch()) {
418                 // The direction of the fling isn't the same as the translation, let's go to 0
419                 newTranslation = 0.0f
420             } else {
421                 newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
422                 // Delay the dismiss a bit to avoid too much overlap. Waiting until the animation
423                 // has finished also feels a bit too slow here.
424                 if (!showsSettingsButton) {
425                     mainExecutor.executeDelayed({ dismissCallback.invoke() }, DISMISS_DELAY)
426                 }
427             }
428             PhysicsAnimator.getInstance(this)
429                 .spring(
430                     CONTENT_TRANSLATION,
431                     newTranslation,
432                     startVelocity = vX,
433                     config = translationConfig
434                 )
435                 .start()
436             scrollView.animationTargetX = newTranslation
437         } else {
438             // We're flinging the player! Let's go either to the previous or to the next player
439             val pos = scrollView.relativeScrollX
440             val currentIndex = if (playerWidthPlusPadding > 0) pos / playerWidthPlusPadding else 0
441             val flungTowardEnd = if (isRtl) vX > 0 else vX < 0
442             var destIndex = if (flungTowardEnd) currentIndex + 1 else currentIndex
443             destIndex = Math.max(0, destIndex)
444             destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex)
445             val view = mediaContent.getChildAt(destIndex)
446             // We need to post this since we're dispatching a touch to the underlying view to cancel
447             // but canceling will actually abort the animation.
448             mainExecutor.execute { scrollView.smoothScrollTo(view.left, scrollView.scrollY) }
449         }
450         return true
451     }
452 
453     /** Reset the translation of the players when swiped */
resetTranslationnull454     fun resetTranslation(animate: Boolean = false) {
455         if (scrollView.getContentTranslation() != 0.0f) {
456             if (animate) {
457                 PhysicsAnimator.getInstance(this)
458                     .spring(CONTENT_TRANSLATION, 0.0f, config = translationConfig)
459                     .start()
460                 scrollView.animationTargetX = 0.0f
461             } else {
462                 PhysicsAnimator.getInstance(this).cancel()
463                 contentTranslation = 0.0f
464             }
465         }
466     }
467 
updateClipToOutlinenull468     private fun updateClipToOutline() {
469         val clip = contentTranslation != 0.0f || scrollIntoCurrentMedia != 0
470         scrollView.clipToOutline = clip
471     }
472 
onMediaScrollingChangednull473     private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) {
474         val wasScrolledIn = scrollIntoCurrentMedia != 0
475         scrollIntoCurrentMedia = scrollInAmount
476         val nowScrolledIn = scrollIntoCurrentMedia != 0
477         if (newIndex != visibleMediaIndex || wasScrolledIn != nowScrolledIn) {
478             val oldIndex = visibleMediaIndex
479             visibleMediaIndex = newIndex
480             if (oldIndex != visibleMediaIndex && visibleToUser) {
481                 logSmartspaceImpression(qsExpanded)
482                 logger.logMediaCarouselPage(newIndex)
483             }
484             closeGuts(false)
485             updatePlayerVisibilities()
486         }
487         val relativeLocation =
488             visibleMediaIndex.toFloat() +
489                 if (playerWidthPlusPadding > 0) {
490                     scrollInAmount.toFloat() / playerWidthPlusPadding
491                 } else {
492                     0f
493                 }
494         // Fix the location, because PageIndicator does not handle RTL internally
495         val location =
496             if (isRtl) {
497                 mediaContent.childCount - relativeLocation - 1
498             } else {
499                 relativeLocation
500             }
501         pageIndicator.setLocation(location)
502         updateClipToOutline()
503     }
504 
505     /** Notified whenever the players or their order has changed */
onPlayersChangednull506     fun onPlayersChanged() {
507         updatePlayerVisibilities()
508         updateMediaPaddings()
509     }
510 
updateMediaPaddingsnull511     private fun updateMediaPaddings() {
512         val padding = scrollView.context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
513         val childCount = mediaContent.childCount
514         for (i in 0 until childCount) {
515             val mediaView = mediaContent.getChildAt(i)
516             val desiredPaddingEnd = if (i == childCount - 1) 0 else padding
517             val layoutParams = mediaView.layoutParams as ViewGroup.MarginLayoutParams
518             if (layoutParams.marginEnd != desiredPaddingEnd) {
519                 layoutParams.marginEnd = desiredPaddingEnd
520                 mediaView.layoutParams = layoutParams
521             }
522         }
523     }
524 
updatePlayerVisibilitiesnull525     private fun updatePlayerVisibilities() {
526         val scrolledIn = scrollIntoCurrentMedia != 0
527         for (i in 0 until mediaContent.childCount) {
528             val view = mediaContent.getChildAt(i)
529             val visible = (i == visibleMediaIndex) || ((i == (visibleMediaIndex + 1)) && scrolledIn)
530             view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
531         }
532     }
533 
534     /**
535      * Notify that a player will be removed right away. This gives us the opporunity to look where
536      * it was and update our scroll position.
537      */
onPrePlayerRemovednull538     fun onPrePlayerRemoved(player: TransitionLayout?) {
539         val removedIndex = mediaContent.indexOfChild(player)
540         // If the removed index is less than the visibleMediaIndex, then we need to decrement it.
541         // RTL has no effect on this, because indices are always relative (start-to-end).
542         // Update the index 'manually' since we won't always get a call to onMediaScrollingChanged
543         val beforeActive = removedIndex <= visibleMediaIndex
544         if (beforeActive) {
545             visibleMediaIndex = Math.max(0, visibleMediaIndex - 1)
546         }
547         // If the removed media item is "left of" the active one (in an absolute sense), we need to
548         // scroll the view to keep that player in view.  This is because scroll position is always
549         // calculated from left to right.
550         // For RTL, we need to scroll if the visible media player is the last item.
551         val leftOfActive = if (isRtl && visibleMediaIndex != 0) !beforeActive else beforeActive
552         if (leftOfActive) {
553             scrollView.scrollX = Math.max(scrollView.scrollX - playerWidthPlusPadding, 0)
554         }
555     }
556 
557     /** Update the bounds of the carousel */
setCarouselBoundsnull558     fun setCarouselBounds(currentCarouselWidth: Int, currentCarouselHeight: Int) {
559         if (currentCarouselHeight != carouselHeight || currentCarouselWidth != carouselHeight) {
560             carouselWidth = currentCarouselWidth
561             carouselHeight = currentCarouselHeight
562             scrollView.invalidateOutline()
563         }
564     }
565 
566     /** Reset the MediaScrollView to the start. */
scrollToStartnull567     fun scrollToStart() {
568         scrollView.relativeScrollX = 0
569     }
570 
571     /**
572      * Smooth scroll to the destination player.
573      *
574      * @param sourceIndex optional source index to indicate where the scroll should begin.
575      * @param destIndex destination index to indicate where the scroll should end.
576      */
scrollToPlayernull577     fun scrollToPlayer(sourceIndex: Int = -1, destIndex: Int) {
578         if (sourceIndex >= 0 && sourceIndex < mediaContent.childCount) {
579             scrollView.relativeScrollX = sourceIndex * playerWidthPlusPadding
580         }
581         val destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex)
582         val view = mediaContent.getChildAt(destIndex)
583         // We need to post this to wait for the active player becomes visible.
584         mainExecutor.executeDelayed(
585             { scrollView.smoothScrollTo(view.left, scrollView.scrollY) },
586             SCROLL_DELAY
587         )
588     }
589 
590     companion object {
591         private val CONTENT_TRANSLATION =
592             object : FloatPropertyCompat<MediaCarouselScrollHandler>("contentTranslation") {
getValuenull593                 override fun getValue(handler: MediaCarouselScrollHandler): Float {
594                     return handler.contentTranslation
595                 }
596 
setValuenull597                 override fun setValue(handler: MediaCarouselScrollHandler, value: Float) {
598                     handler.contentTranslation = value
599                 }
600             }
601     }
602 }
603