• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.animation.Animator
20 import android.animation.AnimatorListenerAdapter
21 import android.animation.ValueAnimator
22 import android.annotation.IntDef
23 import android.content.Context
24 import android.content.res.Configuration
25 import android.database.ContentObserver
26 import android.graphics.Rect
27 import android.net.Uri
28 import android.os.Handler
29 import android.os.UserHandle
30 import android.provider.Settings
31 import android.util.MathUtils
32 import android.view.View
33 import android.view.ViewGroup
34 import android.view.ViewGroupOverlay
35 import androidx.annotation.VisibleForTesting
36 import com.android.keyguard.KeyguardViewController
37 import com.android.systemui.R
38 import com.android.systemui.animation.Interpolators
39 import com.android.systemui.dagger.SysUISingleton
40 import com.android.systemui.dagger.qualifiers.Main
41 import com.android.systemui.dreams.DreamOverlayStateController
42 import com.android.systemui.keyguard.WakefulnessLifecycle
43 import com.android.systemui.media.controls.pipeline.MediaDataManager
44 import com.android.systemui.media.dream.MediaDreamComplication
45 import com.android.systemui.plugins.statusbar.StatusBarStateController
46 import com.android.systemui.shade.ShadeStateEvents
47 import com.android.systemui.shade.ShadeStateEvents.ShadeStateEventsListener
48 import com.android.systemui.statusbar.CrossFadeHelper
49 import com.android.systemui.statusbar.StatusBarState
50 import com.android.systemui.statusbar.SysuiStatusBarStateController
51 import com.android.systemui.statusbar.notification.stack.StackStateAnimator
52 import com.android.systemui.statusbar.phone.KeyguardBypassController
53 import com.android.systemui.statusbar.policy.ConfigurationController
54 import com.android.systemui.statusbar.policy.KeyguardStateController
55 import com.android.systemui.util.LargeScreenUtils
56 import com.android.systemui.util.animation.UniqueObjectHostView
57 import com.android.systemui.util.settings.SecureSettings
58 import com.android.systemui.util.traceSection
59 import javax.inject.Inject
60 
61 private val TAG: String = MediaHierarchyManager::class.java.simpleName
62 
63 /** Similarly to isShown but also excludes views that have 0 alpha */
64 val View.isShownNotFaded: Boolean
65     get() {
66         var current: View = this
67         while (true) {
68             if (current.visibility != View.VISIBLE) {
69                 return false
70             }
71             if (current.alpha == 0.0f) {
72                 return false
73             }
74             val parent = current.parent ?: return false // We are not attached to the view root
75             if (parent !is View) {
76                 // we reached the viewroot, hurray
77                 return true
78             }
79             current = parent
80         }
81     }
82 
83 /**
84  * This manager is responsible for placement of the unique media view between the different hosts
85  * and animate the positions of the views to achieve seamless transitions.
86  */
87 @SysUISingleton
88 class MediaHierarchyManager
89 @Inject
90 constructor(
91     private val context: Context,
92     private val statusBarStateController: SysuiStatusBarStateController,
93     private val keyguardStateController: KeyguardStateController,
94     private val bypassController: KeyguardBypassController,
95     private val mediaCarouselController: MediaCarouselController,
96     private val mediaManager: MediaDataManager,
97     private val keyguardViewController: KeyguardViewController,
98     private val dreamOverlayStateController: DreamOverlayStateController,
99     configurationController: ConfigurationController,
100     wakefulnessLifecycle: WakefulnessLifecycle,
101     panelEventsEvents: ShadeStateEvents,
102     private val secureSettings: SecureSettings,
103     @Main private val handler: Handler,
104 ) {
105 
106     /** Track the media player setting status on lock screen. */
107     private var allowMediaPlayerOnLockScreen: Boolean = true
108     private val lockScreenMediaPlayerUri =
109         secureSettings.getUriFor(Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN)
110 
111     /**
112      * Whether we "skip" QQS during panel expansion.
113      *
114      * This means that when expanding the panel we go directly to QS. Also when we are on QS and
115      * start closing the panel, it fully collapses instead of going to QQS.
116      */
117     private var skipQqsOnExpansion: Boolean = false
118 
119     /**
120      * The root overlay of the hierarchy. This is where the media notification is attached to
121      * whenever the view is transitioning from one host to another. It also make sure that the view
122      * is always in its final state when it is attached to a view host.
123      */
124     private var rootOverlay: ViewGroupOverlay? = null
125 
126     private var rootView: View? = null
127     private var currentBounds = Rect()
128     private var animationStartBounds: Rect = Rect()
129 
130     private var animationStartClipping = Rect()
131     private var currentClipping = Rect()
132     private var targetClipping = Rect()
133 
134     /**
135      * The cross fade progress at the start of the animation. 0.5f means it's just switching between
136      * the start and the end location and the content is fully faded, while 0.75f means that we're
137      * halfway faded in again in the target state.
138      */
139     private var animationStartCrossFadeProgress = 0.0f
140 
141     /** The starting alpha of the animation */
142     private var animationStartAlpha = 0.0f
143 
144     /** The starting location of the cross fade if an animation is running right now. */
145     @MediaLocation private var crossFadeAnimationStartLocation = -1
146 
147     /** The end location of the cross fade if an animation is running right now. */
148     @MediaLocation private var crossFadeAnimationEndLocation = -1
149     private var targetBounds: Rect = Rect()
150     private val mediaFrame
151         get() = mediaCarouselController.mediaFrame
152     private var statusbarState: Int = statusBarStateController.state
153     private var animator =
<lambda>null154         ValueAnimator.ofFloat(0.0f, 1.0f).apply {
155             interpolator = Interpolators.FAST_OUT_SLOW_IN
156             addUpdateListener {
157                 updateTargetState()
158                 val currentAlpha: Float
159                 var boundsProgress = animatedFraction
160                 if (isCrossFadeAnimatorRunning) {
161                     animationCrossFadeProgress =
162                         MathUtils.lerp(animationStartCrossFadeProgress, 1.0f, animatedFraction)
163                     // When crossfading, let's keep the bounds at the right location during fading
164                     boundsProgress = if (animationCrossFadeProgress < 0.5f) 0.0f else 1.0f
165                     currentAlpha = calculateAlphaFromCrossFade(animationCrossFadeProgress)
166                 } else {
167                     // If we're not crossfading, let's interpolate from the start alpha to 1.0f
168                     currentAlpha = MathUtils.lerp(animationStartAlpha, 1.0f, animatedFraction)
169                 }
170                 interpolateBounds(
171                     animationStartBounds,
172                     targetBounds,
173                     boundsProgress,
174                     result = currentBounds
175                 )
176                 resolveClipping(currentClipping)
177                 applyState(currentBounds, currentAlpha, clipBounds = currentClipping)
178             }
179             addListener(
180                 object : AnimatorListenerAdapter() {
181                     private var cancelled: Boolean = false
182 
183                     override fun onAnimationCancel(animation: Animator?) {
184                         cancelled = true
185                         animationPending = false
186                         rootView?.removeCallbacks(startAnimation)
187                     }
188 
189                     override fun onAnimationEnd(animation: Animator?) {
190                         isCrossFadeAnimatorRunning = false
191                         if (!cancelled) {
192                             applyTargetStateIfNotAnimating()
193                         }
194                     }
195 
196                     override fun onAnimationStart(animation: Animator?) {
197                         cancelled = false
198                         animationPending = false
199                     }
200                 }
201             )
202         }
203 
resolveClippingnull204     private fun resolveClipping(result: Rect) {
205         if (animationStartClipping.isEmpty) result.set(targetClipping)
206         else if (targetClipping.isEmpty) result.set(animationStartClipping)
207         else result.setIntersect(animationStartClipping, targetClipping)
208     }
209 
210     private val mediaHosts = arrayOfNulls<MediaHost>(LOCATION_DREAM_OVERLAY + 1)
211     /**
212      * The last location where this view was at before going to the desired location. This is useful
213      * for guided transitions.
214      */
215     @MediaLocation private var previousLocation = -1
216     /** The desired location where the view will be at the end of the transition. */
217     @MediaLocation private var desiredLocation = -1
218 
219     /**
220      * The current attachment location where the view is currently attached. Usually this matches
221      * the desired location except for animations whenever a view moves to the new desired location,
222      * during which it is in [IN_OVERLAY].
223      */
224     @MediaLocation private var currentAttachmentLocation = -1
225 
226     private var inSplitShade = false
227 
228     /** Is there any active media or recommendation in the carousel? */
229     private var hasActiveMediaOrRecommendation: Boolean = false
230         get() = mediaManager.hasActiveMediaOrRecommendation()
231 
232     /** Are we currently waiting on an animation to start? */
233     private var animationPending: Boolean = false
<lambda>null234     private val startAnimation: Runnable = Runnable { animator.start() }
235 
236     /** The expansion of quick settings */
237     var qsExpansion: Float = 0.0f
238         set(value) {
239             if (field != value) {
240                 field = value
241                 updateDesiredLocation()
242                 if (getQSTransformationProgress() >= 0) {
243                     updateTargetState()
244                     applyTargetStateIfNotAnimating()
245                 }
246             }
247         }
248 
249     /** Is quick setting expanded? */
250     var qsExpanded: Boolean = false
251         set(value) {
252             if (field != value) {
253                 field = value
254                 mediaCarouselController.mediaCarouselScrollHandler.qsExpanded = value
255             }
256             // qs is expanded on LS shade and HS shade
257             if (value && (isLockScreenShadeVisibleToUser() || isHomeScreenShadeVisibleToUser())) {
258                 mediaCarouselController.logSmartspaceImpression(value)
259             }
260             mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = isVisibleToUser()
261         }
262 
263     /**
264      * distance that the full shade transition takes in order for media to fully transition to the
265      * shade
266      */
267     private var distanceForFullShadeTransition = 0
268 
269     /**
270      * The amount of progress we are currently in if we're transitioning to the full shade. 0.0f
271      * means we're not transitioning yet, while 1 means we're all the way in the full shade.
272      */
273     private var fullShadeTransitionProgress = 0f
274         set(value) {
275             if (field == value) {
276                 return
277             }
278             field = value
279             if (bypassController.bypassEnabled || statusbarState != StatusBarState.KEYGUARD) {
280                 // No need to do all the calculations / updates below if we're not on the lockscreen
281                 // or if we're bypassing.
282                 return
283             }
284             updateDesiredLocation(forceNoAnimation = isCurrentlyFading())
285             if (value >= 0) {
286                 updateTargetState()
287                 // Setting the alpha directly, as the below call will use it to update the alpha
288                 carouselAlpha = calculateAlphaFromCrossFade(field)
289                 applyTargetStateIfNotAnimating()
290             }
291         }
292 
293     /** Is there currently a cross-fade animation running driven by an animator? */
294     private var isCrossFadeAnimatorRunning = false
295 
296     /**
297      * Are we currently transitionioning from the lockscreen to the full shade
298      * [StatusBarState.SHADE_LOCKED] or [StatusBarState.SHADE]. Once the user has dragged down and
299      * the transition starts, this will no longer return true.
300      */
301     private val isTransitioningToFullShade: Boolean
302         get() =
303             fullShadeTransitionProgress != 0f &&
304                 !bypassController.bypassEnabled &&
305                 statusbarState == StatusBarState.KEYGUARD
306 
307     /**
308      * Set the amount of pixels we have currently dragged down if we're transitioning to the full
309      * shade. 0.0f means we're not transitioning yet.
310      */
setTransitionToFullShadeAmountnull311     fun setTransitionToFullShadeAmount(value: Float) {
312         // If we're transitioning starting on the shade_locked, we don't want any delay and rather
313         // have it aligned with the rest of the animation
314         val progress = MathUtils.saturate(value / distanceForFullShadeTransition)
315         fullShadeTransitionProgress = progress
316     }
317 
318     /**
319      * Returns the amount of translationY of the media container, during the current guided
320      * transformation, if running. If there is no guided transformation running, it will return -1.
321      */
getGuidedTransformationTranslationYnull322     fun getGuidedTransformationTranslationY(): Int {
323         if (!isCurrentlyInGuidedTransformation()) {
324             return -1
325         }
326         val startHost = getHost(previousLocation)
327         if (startHost == null || !startHost.visible) {
328             return 0
329         }
330         return targetBounds.top - startHost.currentBounds.top
331     }
332 
333     /**
334      * Is the shade currently collapsing from the expanded qs? If we're on the lockscreen and in qs,
335      * we wouldn't want to transition in that case.
336      */
337     var collapsingShadeFromQS: Boolean = false
338         set(value) {
339             if (field != value) {
340                 field = value
341                 updateDesiredLocation(forceNoAnimation = true)
342             }
343         }
344 
345     /** Are location changes currently blocked? */
346     private val blockLocationChanges: Boolean
347         get() {
348             return goingToSleep || dozeAnimationRunning
349         }
350 
351     /** Are we currently going to sleep */
352     private var goingToSleep: Boolean = false
353         set(value) {
354             if (field != value) {
355                 field = value
356                 if (!value) {
357                     updateDesiredLocation()
358                 }
359             }
360         }
361 
362     /** Are we currently fullyAwake */
363     private var fullyAwake: Boolean = false
364         set(value) {
365             if (field != value) {
366                 field = value
367                 if (value) {
368                     updateDesiredLocation(forceNoAnimation = true)
369                 }
370             }
371         }
372 
373     /** Is the doze animation currently Running */
374     private var dozeAnimationRunning: Boolean = false
375         private set(value) {
376             if (field != value) {
377                 field = value
378                 if (!value) {
379                     updateDesiredLocation()
380                 }
381             }
382         }
383 
384     /** Is the dream overlay currently active */
385     private var dreamOverlayActive: Boolean = false
386         private set(value) {
387             if (field != value) {
388                 field = value
389                 updateDesiredLocation(forceNoAnimation = true)
390             }
391         }
392 
393     /** Is the dream media complication currently active */
394     private var dreamMediaComplicationActive: Boolean = false
395         private set(value) {
396             if (field != value) {
397                 field = value
398                 updateDesiredLocation(forceNoAnimation = true)
399             }
400         }
401 
402     /**
403      * The current cross fade progress. 0.5f means it's just switching between the start and the end
404      * location and the content is fully faded, while 0.75f means that we're halfway faded in again
405      * in the target state. This is only valid while [isCrossFadeAnimatorRunning] is true.
406      */
407     private var animationCrossFadeProgress = 1.0f
408 
409     /** The current carousel Alpha. */
410     private var carouselAlpha: Float = 1.0f
411         set(value) {
412             if (field == value) {
413                 return
414             }
415             field = value
416             CrossFadeHelper.fadeIn(mediaFrame, value)
417         }
418 
419     /**
420      * Calculate the alpha of the view when given a cross-fade progress.
421      *
422      * @param crossFadeProgress The current cross fade progress. 0.5f means it's just switching
423      *   between the start and the end location and the content is fully faded, while 0.75f means
424      *   that we're halfway faded in again in the target state.
425      */
calculateAlphaFromCrossFadenull426     private fun calculateAlphaFromCrossFade(crossFadeProgress: Float): Float {
427         if (crossFadeProgress <= 0.5f) {
428             return 1.0f - crossFadeProgress / 0.5f
429         } else {
430             return (crossFadeProgress - 0.5f) / 0.5f
431         }
432     }
433 
434     init {
435         updateConfiguration()
436         configurationController.addCallback(
437             object : ConfigurationController.ConfigurationListener {
onConfigChangednull438                 override fun onConfigChanged(newConfig: Configuration?) {
439                     updateConfiguration()
440                     updateDesiredLocation(forceNoAnimation = true, forceStateUpdate = true)
441                 }
442             }
443         )
444         statusBarStateController.addCallback(
445             object : StatusBarStateController.StateListener {
onStatePreChangenull446                 override fun onStatePreChange(oldState: Int, newState: Int) {
447                     // We're updating the location before the state change happens, since we want
448                     // the
449                     // location of the previous state to still be up to date when the animation
450                     // starts
451                     statusbarState = newState
452                     updateDesiredLocation()
453                 }
454 
onStateChangednull455                 override fun onStateChanged(newState: Int) {
456                     updateTargetState()
457                     // Enters shade from lock screen
458                     if (
459                         newState == StatusBarState.SHADE_LOCKED && isLockScreenShadeVisibleToUser()
460                     ) {
461                         mediaCarouselController.logSmartspaceImpression(qsExpanded)
462                     }
463                     mediaCarouselController.mediaCarouselScrollHandler.visibleToUser =
464                         isVisibleToUser()
465                 }
466 
onDozeAmountChangednull467                 override fun onDozeAmountChanged(linear: Float, eased: Float) {
468                     dozeAnimationRunning = linear != 0.0f && linear != 1.0f
469                 }
470 
onDozingChangednull471                 override fun onDozingChanged(isDozing: Boolean) {
472                     if (!isDozing) {
473                         dozeAnimationRunning = false
474                         // Enters lock screen from screen off
475                         if (isLockScreenVisibleToUser()) {
476                             mediaCarouselController.logSmartspaceImpression(qsExpanded)
477                         }
478                     } else {
479                         updateDesiredLocation()
480                         qsExpanded = false
481                         closeGuts()
482                     }
483                     mediaCarouselController.mediaCarouselScrollHandler.visibleToUser =
484                         isVisibleToUser()
485                 }
486 
onExpandedChangednull487                 override fun onExpandedChanged(isExpanded: Boolean) {
488                     // Enters shade from home screen
489                     if (isHomeScreenShadeVisibleToUser()) {
490                         mediaCarouselController.logSmartspaceImpression(qsExpanded)
491                     }
492                     mediaCarouselController.mediaCarouselScrollHandler.visibleToUser =
493                         isVisibleToUser()
494                 }
495             }
496         )
497 
498         dreamOverlayStateController.addCallback(
499             object : DreamOverlayStateController.Callback {
onComplicationsChangednull500                 override fun onComplicationsChanged() {
501                     dreamMediaComplicationActive =
502                         dreamOverlayStateController.complications.any {
503                             it is MediaDreamComplication
504                         }
505                 }
506 
onStateChangednull507                 override fun onStateChanged() {
508                     dreamOverlayStateController.isOverlayActive.also { dreamOverlayActive = it }
509                 }
510             }
511         )
512 
513         wakefulnessLifecycle.addObserver(
514             object : WakefulnessLifecycle.Observer {
onFinishedGoingToSleepnull515                 override fun onFinishedGoingToSleep() {
516                     goingToSleep = false
517                 }
518 
onStartedGoingToSleepnull519                 override fun onStartedGoingToSleep() {
520                     goingToSleep = true
521                     fullyAwake = false
522                 }
523 
onFinishedWakingUpnull524                 override fun onFinishedWakingUp() {
525                     goingToSleep = false
526                     fullyAwake = true
527                 }
528 
onStartedWakingUpnull529                 override fun onStartedWakingUp() {
530                     goingToSleep = false
531                 }
532             }
533         )
534 
<lambda>null535         mediaCarouselController.updateUserVisibility = {
536             mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = isVisibleToUser()
537         }
<lambda>null538         mediaCarouselController.updateHostVisibility = {
539             mediaHosts.forEach { it?.updateViewVisibility() }
540         }
541 
542         panelEventsEvents.addShadeStateEventsListener(
543             object : ShadeStateEventsListener {
onExpandImmediateChangednull544                 override fun onExpandImmediateChanged(isExpandImmediateEnabled: Boolean) {
545                     skipQqsOnExpansion = isExpandImmediateEnabled
546                     updateDesiredLocation()
547                 }
548             }
549         )
550 
551         val settingsObserver: ContentObserver =
552             object : ContentObserver(handler) {
onChangenull553                 override fun onChange(selfChange: Boolean, uri: Uri?) {
554                     if (uri == lockScreenMediaPlayerUri) {
555                         allowMediaPlayerOnLockScreen =
556                             secureSettings.getBoolForUser(
557                                 Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN,
558                                 true,
559                                 UserHandle.USER_CURRENT
560                             )
561                     }
562                 }
563             }
564         secureSettings.registerContentObserverForUser(
565             Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN,
566             settingsObserver,
567             UserHandle.USER_ALL
568         )
569     }
570 
updateConfigurationnull571     private fun updateConfiguration() {
572         distanceForFullShadeTransition =
573             context.resources.getDimensionPixelSize(
574                 R.dimen.lockscreen_shade_media_transition_distance
575             )
576         inSplitShade = LargeScreenUtils.shouldUseSplitNotificationShade(context.resources)
577     }
578 
579     /**
580      * Register a media host and create a view can be attached to a view hierarchy and where the
581      * players will be placed in when the host is the currently desired state.
582      *
583      * @return the hostView associated with this location
584      */
registernull585     fun register(mediaObject: MediaHost): UniqueObjectHostView {
586         val viewHost = createUniqueObjectHost()
587         mediaObject.hostView = viewHost
588         mediaObject.addVisibilityChangeListener {
589             // Never animate because of a visibility change, only state changes should do that
590             updateDesiredLocation(forceNoAnimation = true)
591         }
592         mediaHosts[mediaObject.location] = mediaObject
593         if (mediaObject.location == desiredLocation) {
594             // In case we are overriding a view that is already visible, make sure we attach it
595             // to this new host view in the below call
596             desiredLocation = -1
597         }
598         if (mediaObject.location == currentAttachmentLocation) {
599             currentAttachmentLocation = -1
600         }
601         updateDesiredLocation()
602         return viewHost
603     }
604 
605     /** Close the guts in all players in [MediaCarouselController]. */
closeGutsnull606     fun closeGuts() {
607         mediaCarouselController.closeGuts()
608     }
609 
createUniqueObjectHostnull610     private fun createUniqueObjectHost(): UniqueObjectHostView {
611         val viewHost = UniqueObjectHostView(context)
612         viewHost.addOnAttachStateChangeListener(
613             object : View.OnAttachStateChangeListener {
614                 override fun onViewAttachedToWindow(p0: View?) {
615                     if (rootOverlay == null) {
616                         rootView = viewHost.viewRootImpl.view
617                         rootOverlay = (rootView!!.overlay as ViewGroupOverlay)
618                     }
619                     viewHost.removeOnAttachStateChangeListener(this)
620                 }
621 
622                 override fun onViewDetachedFromWindow(p0: View?) {}
623             }
624         )
625         return viewHost
626     }
627 
628     /**
629      * Updates the location that the view should be in. If it changes, an animation may be triggered
630      * going from the old desired location to the new one.
631      *
632      * @param forceNoAnimation optional parameter telling the system not to animate
633      * @param forceStateUpdate optional parameter telling the system to update transition state
634      *
635      * ```
636      *                         even if location did not change
637      * ```
638      */
updateDesiredLocationnull639     private fun updateDesiredLocation(
640         forceNoAnimation: Boolean = false,
641         forceStateUpdate: Boolean = false
642     ) =
643         traceSection("MediaHierarchyManager#updateDesiredLocation") {
644             val desiredLocation = calculateLocation()
645             if (
646                 desiredLocation != this.desiredLocation || forceStateUpdate && !blockLocationChanges
647             ) {
648                 if (this.desiredLocation >= 0 && desiredLocation != this.desiredLocation) {
649                     // Only update previous location when it actually changes
650                     previousLocation = this.desiredLocation
651                 } else if (forceStateUpdate) {
652                     val onLockscreen =
653                         (!bypassController.bypassEnabled &&
654                             (statusbarState == StatusBarState.KEYGUARD))
655                     if (
656                         desiredLocation == LOCATION_QS &&
657                             previousLocation == LOCATION_LOCKSCREEN &&
658                             !onLockscreen
659                     ) {
660                         // If media active state changed and the device is now unlocked, update the
661                         // previous location so we animate between the correct hosts
662                         previousLocation = LOCATION_QQS
663                     }
664                 }
665                 val isNewView = this.desiredLocation == -1
666                 this.desiredLocation = desiredLocation
667                 // Let's perform a transition
668                 val animate =
669                     !forceNoAnimation && shouldAnimateTransition(desiredLocation, previousLocation)
670                 val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation)
671                 val host = getHost(desiredLocation)
672                 val willFade = calculateTransformationType() == TRANSFORMATION_TYPE_FADE
673                 if (!willFade || isCurrentlyInGuidedTransformation() || !animate) {
674                     // if we're fading, we want the desired location / measurement only to change
675                     // once fully faded. This is happening in the host attachment
676                     mediaCarouselController.onDesiredLocationChanged(
677                         desiredLocation,
678                         host,
679                         animate,
680                         animDuration,
681                         delay
682                     )
683                 }
684                 performTransitionToNewLocation(isNewView, animate)
685             }
686         }
687 
performTransitionToNewLocationnull688     private fun performTransitionToNewLocation(isNewView: Boolean, animate: Boolean) =
689         traceSection("MediaHierarchyManager#performTransitionToNewLocation") {
690             if (previousLocation < 0 || isNewView) {
691                 cancelAnimationAndApplyDesiredState()
692                 return
693             }
694             val currentHost = getHost(desiredLocation)
695             val previousHost = getHost(previousLocation)
696             if (currentHost == null || previousHost == null) {
697                 cancelAnimationAndApplyDesiredState()
698                 return
699             }
700             updateTargetState()
701             if (isCurrentlyInGuidedTransformation()) {
702                 applyTargetStateIfNotAnimating()
703             } else if (animate) {
704                 val wasCrossFading = isCrossFadeAnimatorRunning
705                 val previewsCrossFadeProgress = animationCrossFadeProgress
706                 animator.cancel()
707                 if (
708                     currentAttachmentLocation != previousLocation ||
709                         !previousHost.hostView.isAttachedToWindow
710                 ) {
711                     // Let's animate to the new position, starting from the current position
712                     // We also go in here in case the view was detached, since the bounds wouldn't
713                     // be correct anymore
714                     animationStartBounds.set(currentBounds)
715                     animationStartClipping.set(currentClipping)
716                 } else {
717                     // otherwise, let's take the freshest state, since the current one could
718                     // be outdated
719                     animationStartBounds.set(previousHost.currentBounds)
720                     animationStartClipping.set(previousHost.currentClipping)
721                 }
722                 val transformationType = calculateTransformationType()
723                 var needsCrossFade = transformationType == TRANSFORMATION_TYPE_FADE
724                 var crossFadeStartProgress = 0.0f
725                 // The alpha is only relevant when not cross fading
726                 var newCrossFadeStartLocation = previousLocation
727                 if (wasCrossFading) {
728                     if (currentAttachmentLocation == crossFadeAnimationEndLocation) {
729                         if (needsCrossFade) {
730                             // We were previously crossFading and we've already reached
731                             // the end view, Let's start crossfading from the same position there
732                             crossFadeStartProgress = 1.0f - previewsCrossFadeProgress
733                         }
734                         // Otherwise let's fade in from the current alpha, but not cross fade
735                     } else {
736                         // We haven't reached the previous location yet, let's still cross fade from
737                         // where we were.
738                         newCrossFadeStartLocation = crossFadeAnimationStartLocation
739                         if (newCrossFadeStartLocation == desiredLocation) {
740                             // we're crossFading back to where we were, let's start at the end
741                             // position
742                             crossFadeStartProgress = 1.0f - previewsCrossFadeProgress
743                         } else {
744                             // Let's start from where we are right now
745                             crossFadeStartProgress = previewsCrossFadeProgress
746                             // We need to force cross fading as we haven't reached the end location
747                             // yet
748                             needsCrossFade = true
749                         }
750                     }
751                 } else if (needsCrossFade) {
752                     // let's not flicker and start with the same alpha
753                     crossFadeStartProgress = (1.0f - carouselAlpha) / 2.0f
754                 }
755                 isCrossFadeAnimatorRunning = needsCrossFade
756                 crossFadeAnimationStartLocation = newCrossFadeStartLocation
757                 crossFadeAnimationEndLocation = desiredLocation
758                 animationStartAlpha = carouselAlpha
759                 animationStartCrossFadeProgress = crossFadeStartProgress
760                 adjustAnimatorForTransition(desiredLocation, previousLocation)
761                 if (!animationPending) {
762                     rootView?.let {
763                         // Let's delay the animation start until we finished laying out
764                         animationPending = true
765                         it.postOnAnimation(startAnimation)
766                     }
767                 }
768             } else {
769                 cancelAnimationAndApplyDesiredState()
770             }
771         }
772 
shouldAnimateTransitionnull773     private fun shouldAnimateTransition(
774         @MediaLocation currentLocation: Int,
775         @MediaLocation previousLocation: Int
776     ): Boolean {
777         if (isCurrentlyInGuidedTransformation()) {
778             return false
779         }
780         if (skipQqsOnExpansion) {
781             return false
782         }
783         // This is an invalid transition, and can happen when using the camera gesture from the
784         // lock screen. Disallow.
785         if (
786             previousLocation == LOCATION_LOCKSCREEN &&
787                 desiredLocation == LOCATION_QQS &&
788                 statusbarState == StatusBarState.SHADE
789         ) {
790             return false
791         }
792 
793         if (
794             currentLocation == LOCATION_QQS &&
795                 previousLocation == LOCATION_LOCKSCREEN &&
796                 (statusBarStateController.leaveOpenOnKeyguardHide() ||
797                     statusbarState == StatusBarState.SHADE_LOCKED)
798         ) {
799             // Usually listening to the isShown is enough to determine this, but there is some
800             // non-trivial reattaching logic happening that will make the view not-shown earlier
801             return true
802         }
803 
804         if (
805             desiredLocation == LOCATION_QS &&
806                 previousLocation == LOCATION_LOCKSCREEN &&
807                 statusbarState == StatusBarState.SHADE
808         ) {
809             // This is an invalid transition, can happen when tapping on home control and the UMO
810             // while being on landscape orientation in tablet.
811             return false
812         }
813 
814         if (
815             statusbarState == StatusBarState.KEYGUARD &&
816                 (currentLocation == LOCATION_LOCKSCREEN || previousLocation == LOCATION_LOCKSCREEN)
817         ) {
818             // We're always fading from lockscreen to keyguard in situations where the player
819             // is already fully hidden
820             return false
821         }
822         return mediaFrame.isShownNotFaded || animator.isRunning || animationPending
823     }
824 
adjustAnimatorForTransitionnull825     private fun adjustAnimatorForTransition(desiredLocation: Int, previousLocation: Int) {
826         val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation)
827         animator.apply {
828             duration = animDuration
829             startDelay = delay
830         }
831     }
832 
getAnimationParamsnull833     private fun getAnimationParams(previousLocation: Int, desiredLocation: Int): Pair<Long, Long> {
834         var animDuration = 200L
835         var delay = 0L
836         if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) {
837             // Going to the full shade, let's adjust the animation duration
838             if (
839                 statusbarState == StatusBarState.SHADE &&
840                     keyguardStateController.isKeyguardFadingAway
841             ) {
842                 delay = keyguardStateController.keyguardFadingAwayDelay
843             }
844             animDuration = (StackStateAnimator.ANIMATION_DURATION_GO_TO_FULL_SHADE / 2f).toLong()
845         } else if (previousLocation == LOCATION_QQS && desiredLocation == LOCATION_LOCKSCREEN) {
846             animDuration = StackStateAnimator.ANIMATION_DURATION_APPEAR_DISAPPEAR.toLong()
847         }
848         return animDuration to delay
849     }
850 
applyTargetStateIfNotAnimatingnull851     private fun applyTargetStateIfNotAnimating() {
852         if (!animator.isRunning) {
853             // Let's immediately apply the target state (which is interpolated) if there is
854             // no animation running. Otherwise the animation update will already update
855             // the location
856             applyState(targetBounds, carouselAlpha, clipBounds = targetClipping)
857         }
858     }
859 
860     /** Updates the bounds that the view wants to be in at the end of the animation. */
updateTargetStatenull861     private fun updateTargetState() {
862         var starthost = getHost(previousLocation)
863         var endHost = getHost(desiredLocation)
864         if (
865             isCurrentlyInGuidedTransformation() &&
866                 !isCurrentlyFading() &&
867                 starthost != null &&
868                 endHost != null
869         ) {
870             val progress = getTransformationProgress()
871             // If either of the hosts are invisible, let's keep them at the other host location to
872             // have a nicer disappear animation. Otherwise the currentBounds of the state might
873             // be undefined
874             if (!endHost.visible) {
875                 endHost = starthost
876             } else if (!starthost.visible) {
877                 starthost = endHost
878             }
879             val newBounds = endHost.currentBounds
880             val previousBounds = starthost.currentBounds
881             targetBounds = interpolateBounds(previousBounds, newBounds, progress)
882             targetClipping = endHost.currentClipping
883         } else if (endHost != null) {
884             val bounds = endHost.currentBounds
885             targetBounds.set(bounds)
886             targetClipping = endHost.currentClipping
887         }
888     }
889 
interpolateBoundsnull890     private fun interpolateBounds(
891         startBounds: Rect,
892         endBounds: Rect,
893         progress: Float,
894         result: Rect? = null
895     ): Rect {
896         val left =
897             MathUtils.lerp(startBounds.left.toFloat(), endBounds.left.toFloat(), progress).toInt()
898         val top =
899             MathUtils.lerp(startBounds.top.toFloat(), endBounds.top.toFloat(), progress).toInt()
900         val right =
901             MathUtils.lerp(startBounds.right.toFloat(), endBounds.right.toFloat(), progress).toInt()
902         val bottom =
903             MathUtils.lerp(startBounds.bottom.toFloat(), endBounds.bottom.toFloat(), progress)
904                 .toInt()
905         val resultBounds = result ?: Rect()
906         resultBounds.set(left, top, right, bottom)
907         return resultBounds
908     }
909 
910     /** @return true if this transformation is guided by an external progress like a finger */
isCurrentlyInGuidedTransformationnull911     fun isCurrentlyInGuidedTransformation(): Boolean {
912         return hasValidStartAndEndLocations() &&
913             getTransformationProgress() >= 0 &&
914             (areGuidedTransitionHostsVisible() || !hasActiveMediaOrRecommendation)
915     }
916 
hasValidStartAndEndLocationsnull917     private fun hasValidStartAndEndLocations(): Boolean {
918         return previousLocation != -1 && desiredLocation != -1
919     }
920 
921     /** Calculate the transformation type for the current animation */
922     @VisibleForTesting
923     @TransformationType
calculateTransformationTypenull924     fun calculateTransformationType(): Int {
925         if (isTransitioningToFullShade) {
926             if (inSplitShade && areGuidedTransitionHostsVisible()) {
927                 return TRANSFORMATION_TYPE_TRANSITION
928             }
929             return TRANSFORMATION_TYPE_FADE
930         }
931         if (
932             previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QS ||
933                 previousLocation == LOCATION_QS && desiredLocation == LOCATION_LOCKSCREEN
934         ) {
935             // animating between ls and qs should fade, as QS is clipped.
936             return TRANSFORMATION_TYPE_FADE
937         }
938         if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) {
939             // animating between ls and qqs should fade when dragging down via e.g. expand button
940             return TRANSFORMATION_TYPE_FADE
941         }
942         return TRANSFORMATION_TYPE_TRANSITION
943     }
944 
areGuidedTransitionHostsVisiblenull945     private fun areGuidedTransitionHostsVisible(): Boolean {
946         return getHost(previousLocation)?.visible == true &&
947             getHost(desiredLocation)?.visible == true
948     }
949 
950     /**
951      * @return the current transformation progress if we're in a guided transformation and -1
952      *   otherwise
953      */
getTransformationProgressnull954     private fun getTransformationProgress(): Float {
955         if (skipQqsOnExpansion) {
956             return -1.0f
957         }
958         val progress = getQSTransformationProgress()
959         if (statusbarState != StatusBarState.KEYGUARD && progress >= 0) {
960             return progress
961         }
962         if (isTransitioningToFullShade) {
963             return fullShadeTransitionProgress
964         }
965         return -1.0f
966     }
967 
getQSTransformationProgressnull968     private fun getQSTransformationProgress(): Float {
969         val currentHost = getHost(desiredLocation)
970         val previousHost = getHost(previousLocation)
971         if (currentHost?.location == LOCATION_QS && !inSplitShade) {
972             if (previousHost?.location == LOCATION_QQS) {
973                 if (previousHost.visible || statusbarState != StatusBarState.KEYGUARD) {
974                     return qsExpansion
975                 }
976             }
977         }
978         return -1.0f
979     }
980 
getHostnull981     private fun getHost(@MediaLocation location: Int): MediaHost? {
982         if (location < 0) {
983             return null
984         }
985         return mediaHosts[location]
986     }
987 
cancelAnimationAndApplyDesiredStatenull988     private fun cancelAnimationAndApplyDesiredState() {
989         animator.cancel()
990         getHost(desiredLocation)?.let {
991             applyState(it.currentBounds, alpha = 1.0f, immediately = true)
992         }
993     }
994 
995     /** Apply the current state to the view, updating it's bounds and desired state */
applyStatenull996     private fun applyState(
997         bounds: Rect,
998         alpha: Float,
999         immediately: Boolean = false,
1000         clipBounds: Rect = EMPTY_RECT
1001     ) =
1002         traceSection("MediaHierarchyManager#applyState") {
1003             currentBounds.set(bounds)
1004             currentClipping = clipBounds
1005             carouselAlpha = if (isCurrentlyFading()) alpha else 1.0f
1006             val onlyUseEndState = !isCurrentlyInGuidedTransformation() || isCurrentlyFading()
1007             val startLocation = if (onlyUseEndState) -1 else previousLocation
1008             val progress = if (onlyUseEndState) 1.0f else getTransformationProgress()
1009             val endLocation = resolveLocationForFading()
1010             mediaCarouselController.setCurrentState(
1011                 startLocation,
1012                 endLocation,
1013                 progress,
1014                 immediately
1015             )
1016             updateHostAttachment()
1017             if (currentAttachmentLocation == IN_OVERLAY) {
1018                 // Setting the clipping on the hierarchy of `mediaFrame` does not work
1019                 if (!currentClipping.isEmpty) {
1020                     currentBounds.intersect(currentClipping)
1021                 }
1022                 mediaFrame.setLeftTopRightBottom(
1023                     currentBounds.left,
1024                     currentBounds.top,
1025                     currentBounds.right,
1026                     currentBounds.bottom
1027                 )
1028             }
1029         }
1030 
updateHostAttachmentnull1031     private fun updateHostAttachment() =
1032         traceSection("MediaHierarchyManager#updateHostAttachment") {
1033             var newLocation = resolveLocationForFading()
1034             // Don't use the overlay when fading or when we don't have active media
1035             var canUseOverlay = !isCurrentlyFading() && hasActiveMediaOrRecommendation
1036             if (isCrossFadeAnimatorRunning) {
1037                 if (
1038                     getHost(newLocation)?.visible == true &&
1039                         getHost(newLocation)?.hostView?.isShown == false &&
1040                         newLocation != desiredLocation
1041                 ) {
1042                     // We're crossfading but the view is already hidden. Let's move to the overlay
1043                     // instead. This happens when animating to the full shade using a button click.
1044                     canUseOverlay = true
1045                 }
1046             }
1047             val inOverlay = isTransitionRunning() && rootOverlay != null && canUseOverlay
1048             newLocation = if (inOverlay) IN_OVERLAY else newLocation
1049             if (currentAttachmentLocation != newLocation) {
1050                 currentAttachmentLocation = newLocation
1051 
1052                 // Remove the carousel from the old host
1053                 (mediaFrame.parent as ViewGroup?)?.removeView(mediaFrame)
1054 
1055                 // Add it to the new one
1056                 if (inOverlay) {
1057                     rootOverlay!!.add(mediaFrame)
1058                 } else {
1059                     val targetHost = getHost(newLocation)!!.hostView
1060                     // This will either do a full layout pass and remeasure, or it will bypass
1061                     // that and directly set the mediaFrame's bounds within the premeasured host.
1062                     targetHost.addView(mediaFrame)
1063                 }
1064                 if (isCrossFadeAnimatorRunning) {
1065                     // When cross-fading with an animation, we only notify the media carousel of the
1066                     // location change, once the view is reattached to the new place and not
1067                     // immediately
1068                     // when the desired location changes. This callback will update the measurement
1069                     // of the carousel, only once we've faded out at the old location and then
1070                     // reattach
1071                     // to fade it in at the new location.
1072                     mediaCarouselController.onDesiredLocationChanged(
1073                         newLocation,
1074                         getHost(newLocation),
1075                         animate = false
1076                     )
1077                 }
1078             }
1079         }
1080 
1081     /**
1082      * Calculate the location when cross fading between locations. While fading out, the content
1083      * should remain in the previous location, while after the switch it should be at the desired
1084      * location.
1085      */
resolveLocationForFadingnull1086     private fun resolveLocationForFading(): Int {
1087         if (isCrossFadeAnimatorRunning) {
1088             // When animating between two hosts with a fade, let's keep ourselves in the old
1089             // location for the first half, and then switch over to the end location
1090             if (animationCrossFadeProgress > 0.5 || previousLocation == -1) {
1091                 return crossFadeAnimationEndLocation
1092             } else {
1093                 return crossFadeAnimationStartLocation
1094             }
1095         }
1096         return desiredLocation
1097     }
1098 
isTransitionRunningnull1099     private fun isTransitionRunning(): Boolean {
1100         return isCurrentlyInGuidedTransformation() && getTransformationProgress() != 1.0f ||
1101             animator.isRunning ||
1102             animationPending
1103     }
1104 
1105     @MediaLocation
calculateLocationnull1106     private fun calculateLocation(): Int {
1107         if (blockLocationChanges) {
1108             // Keep the current location until we're allowed to again
1109             return desiredLocation
1110         }
1111         val onLockscreen =
1112             (!bypassController.bypassEnabled && (statusbarState == StatusBarState.KEYGUARD))
1113         val location =
1114             when {
1115                 dreamOverlayActive && dreamMediaComplicationActive -> LOCATION_DREAM_OVERLAY
1116                 (qsExpansion > 0.0f || inSplitShade) && !onLockscreen -> LOCATION_QS
1117                 qsExpansion > 0.4f && onLockscreen -> LOCATION_QS
1118                 onLockscreen && isSplitShadeExpanding() -> LOCATION_QS
1119                 onLockscreen && isTransformingToFullShadeAndInQQS() -> LOCATION_QQS
1120                 onLockscreen && allowMediaPlayerOnLockScreen -> LOCATION_LOCKSCREEN
1121                 else -> LOCATION_QQS
1122             }
1123         // When we're on lock screen and the player is not active, we should keep it in QS.
1124         // Otherwise it will try to animate a transition that doesn't make sense.
1125         if (
1126             location == LOCATION_LOCKSCREEN &&
1127                 getHost(location)?.visible != true &&
1128                 !statusBarStateController.isDozing
1129         ) {
1130             return LOCATION_QS
1131         }
1132         if (
1133             location == LOCATION_LOCKSCREEN &&
1134                 desiredLocation == LOCATION_QS &&
1135                 collapsingShadeFromQS
1136         ) {
1137             // When collapsing on the lockscreen, we want to remain in QS
1138             return LOCATION_QS
1139         }
1140         if (
1141             location != LOCATION_LOCKSCREEN && desiredLocation == LOCATION_LOCKSCREEN && !fullyAwake
1142         ) {
1143             // When unlocking from dozing / while waking up, the media shouldn't be transitioning
1144             // in an animated way. Let's keep it in the lockscreen until we're fully awake and
1145             // reattach it without an animation
1146             return LOCATION_LOCKSCREEN
1147         }
1148         if (skipQqsOnExpansion) {
1149             // When doing an immediate expand or collapse, we want to keep it in QS.
1150             return LOCATION_QS
1151         }
1152         return location
1153     }
1154 
isSplitShadeExpandingnull1155     private fun isSplitShadeExpanding(): Boolean {
1156         return inSplitShade && isTransitioningToFullShade
1157     }
1158 
1159     /** Are we currently transforming to the full shade and already in QQS */
isTransformingToFullShadeAndInQQSnull1160     private fun isTransformingToFullShadeAndInQQS(): Boolean {
1161         if (!isTransitioningToFullShade) {
1162             return false
1163         }
1164         if (inSplitShade) {
1165             // Split shade doesn't use QQS.
1166             return false
1167         }
1168         return fullShadeTransitionProgress > 0.5f
1169     }
1170 
1171     /** Is the current transformationType fading */
isCurrentlyFadingnull1172     private fun isCurrentlyFading(): Boolean {
1173         if (isSplitShadeExpanding()) {
1174             // Split shade always uses transition instead of fade.
1175             return false
1176         }
1177         if (isTransitioningToFullShade) {
1178             return true
1179         }
1180         return isCrossFadeAnimatorRunning
1181     }
1182 
1183     /** Returns true when the media card could be visible to the user if existed. */
isVisibleToUsernull1184     private fun isVisibleToUser(): Boolean {
1185         return isLockScreenVisibleToUser() ||
1186             isLockScreenShadeVisibleToUser() ||
1187             isHomeScreenShadeVisibleToUser()
1188     }
1189 
isLockScreenVisibleToUsernull1190     private fun isLockScreenVisibleToUser(): Boolean {
1191         return !statusBarStateController.isDozing &&
1192             !keyguardViewController.isBouncerShowing &&
1193             statusBarStateController.state == StatusBarState.KEYGUARD &&
1194             allowMediaPlayerOnLockScreen &&
1195             statusBarStateController.isExpanded &&
1196             !qsExpanded
1197     }
1198 
isLockScreenShadeVisibleToUsernull1199     private fun isLockScreenShadeVisibleToUser(): Boolean {
1200         return !statusBarStateController.isDozing &&
1201             !keyguardViewController.isBouncerShowing &&
1202             (statusBarStateController.state == StatusBarState.SHADE_LOCKED ||
1203                 (statusBarStateController.state == StatusBarState.KEYGUARD && qsExpanded))
1204     }
1205 
isHomeScreenShadeVisibleToUsernull1206     private fun isHomeScreenShadeVisibleToUser(): Boolean {
1207         return !statusBarStateController.isDozing &&
1208             statusBarStateController.state == StatusBarState.SHADE &&
1209             statusBarStateController.isExpanded
1210     }
1211 
1212     companion object {
1213         /** Attached in expanded quick settings */
1214         const val LOCATION_QS = 0
1215 
1216         /** Attached in the collapsed QS */
1217         const val LOCATION_QQS = 1
1218 
1219         /** Attached on the lock screen */
1220         const val LOCATION_LOCKSCREEN = 2
1221 
1222         /** Attached on the dream overlay */
1223         const val LOCATION_DREAM_OVERLAY = 3
1224 
1225         /** Attached at the root of the hierarchy in an overlay */
1226         const val IN_OVERLAY = -1000
1227 
1228         /**
1229          * The default transformation type where the hosts transform into each other using a direct
1230          * transition
1231          */
1232         const val TRANSFORMATION_TYPE_TRANSITION = 0
1233 
1234         /**
1235          * A transformation type where content fades from one place to another instead of
1236          * transitioning
1237          */
1238         const val TRANSFORMATION_TYPE_FADE = 1
1239     }
1240 }
1241 
1242 private val EMPTY_RECT = Rect()
1243 
1244 @IntDef(
1245     prefix = ["TRANSFORMATION_TYPE_"],
1246     value =
1247         [
1248             MediaHierarchyManager.TRANSFORMATION_TYPE_TRANSITION,
1249             MediaHierarchyManager.TRANSFORMATION_TYPE_FADE
1250         ]
1251 )
1252 @Retention(AnnotationRetention.SOURCE)
1253 private annotation class TransformationType
1254 
1255 @IntDef(
1256     prefix = ["LOCATION_"],
1257     value =
1258         [
1259             MediaHierarchyManager.LOCATION_QS,
1260             MediaHierarchyManager.LOCATION_QQS,
1261             MediaHierarchyManager.LOCATION_LOCKSCREEN,
1262             MediaHierarchyManager.LOCATION_DREAM_OVERLAY
1263         ]
1264 )
1265 @Retention(AnnotationRetention.SOURCE)
1266 annotation class MediaLocation
1267