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