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
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.graphics.Rect
25 import android.util.MathUtils
26 import android.view.View
27 import android.view.ViewGroup
28 import android.view.ViewGroupOverlay
29 import com.android.systemui.Interpolators
30 import com.android.systemui.keyguard.WakefulnessLifecycle
31 import com.android.systemui.plugins.statusbar.StatusBarStateController
32 import com.android.systemui.statusbar.NotificationLockscreenUserManager
33 import com.android.systemui.statusbar.StatusBarState
34 import com.android.systemui.statusbar.SysuiStatusBarStateController
35 import com.android.systemui.statusbar.notification.stack.StackStateAnimator
36 import com.android.systemui.statusbar.phone.KeyguardBypassController
37 import com.android.systemui.statusbar.policy.KeyguardStateController
38 import com.android.systemui.util.animation.UniqueObjectHostView
39 import javax.inject.Inject
40 import javax.inject.Singleton
41 
42 /**
43  * Similarly to isShown but also excludes views that have 0 alpha
44  */
45 val View.isShownNotFaded: Boolean
46     get() {
47         var current: View = this
48         while (true) {
49             if (current.visibility != View.VISIBLE) {
50                 return false
51             }
52             if (current.alpha == 0.0f) {
53                 return false
54             }
55             val parent = current.parent ?: return false // We are not attached to the view root
56             if (parent !is View) {
57                 // we reached the viewroot, hurray
58                 return true
59             }
60             current = parent
61         }
62     }
63 
64 /**
65  * This manager is responsible for placement of the unique media view between the different hosts
66  * and animate the positions of the views to achieve seamless transitions.
67  */
68 @Singleton
69 class MediaHierarchyManager @Inject constructor(
70     private val context: Context,
71     private val statusBarStateController: SysuiStatusBarStateController,
72     private val keyguardStateController: KeyguardStateController,
73     private val bypassController: KeyguardBypassController,
74     private val mediaCarouselController: MediaCarouselController,
75     private val notifLockscreenUserManager: NotificationLockscreenUserManager,
76     wakefulnessLifecycle: WakefulnessLifecycle
77 ) {
78     /**
79      * The root overlay of the hierarchy. This is where the media notification is attached to
80      * whenever the view is transitioning from one host to another. It also make sure that the
81      * view is always in its final state when it is attached to a view host.
82      */
83     private var rootOverlay: ViewGroupOverlay? = null
84 
85     private var rootView: View? = null
86     private var currentBounds = Rect()
87     private var animationStartBounds: Rect = Rect()
88     private var targetBounds: Rect = Rect()
89     private val mediaFrame
90         get() = mediaCarouselController.mediaFrame
91     private var statusbarState: Int = statusBarStateController.state
<lambda>null92     private var animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply {
93         interpolator = Interpolators.FAST_OUT_SLOW_IN
94         addUpdateListener {
95             updateTargetState()
96             interpolateBounds(animationStartBounds, targetBounds, animatedFraction,
97                     result = currentBounds)
98             applyState(currentBounds)
99         }
100         addListener(object : AnimatorListenerAdapter() {
101             private var cancelled: Boolean = false
102 
103             override fun onAnimationCancel(animation: Animator?) {
104                 cancelled = true
105                 animationPending = false
106                 rootView?.removeCallbacks(startAnimation)
107             }
108 
109             override fun onAnimationEnd(animation: Animator?) {
110                 if (!cancelled) {
111                     applyTargetStateIfNotAnimating()
112                 }
113             }
114 
115             override fun onAnimationStart(animation: Animator?) {
116                 cancelled = false
117                 animationPending = false
118             }
119         })
120     }
121 
122     private val mediaHosts = arrayOfNulls<MediaHost>(LOCATION_LOCKSCREEN + 1)
123     /**
124      * The last location where this view was at before going to the desired location. This is
125      * useful for guided transitions.
126      */
127     @MediaLocation
128     private var previousLocation = -1
129     /**
130      * The desired location where the view will be at the end of the transition.
131      */
132     @MediaLocation
133     private var desiredLocation = -1
134 
135     /**
136      * The current attachment location where the view is currently attached.
137      * Usually this matches the desired location except for animations whenever a view moves
138      * to the new desired location, during which it is in [IN_OVERLAY].
139      */
140     @MediaLocation
141     private var currentAttachmentLocation = -1
142 
143     /**
144      * Are we currently waiting on an animation to start?
145      */
146     private var animationPending: Boolean = false
<lambda>null147     private val startAnimation: Runnable = Runnable { animator.start() }
148 
149     /**
150      * The expansion of quick settings
151      */
152     var qsExpansion: Float = 0.0f
153         set(value) {
154             if (field != value) {
155                 field = value
156                 updateDesiredLocation()
157                 if (getQSTransformationProgress() >= 0) {
158                     updateTargetState()
159                     applyTargetStateIfNotAnimating()
160                 }
161             }
162         }
163 
164     /**
165      * Is the shade currently collapsing from the expanded qs? If we're on the lockscreen and in qs,
166      * we wouldn't want to transition in that case.
167      */
168     var collapsingShadeFromQS: Boolean = false
169         set(value) {
170             if (field != value) {
171                 field = value
172                 updateDesiredLocation(forceNoAnimation = true)
173             }
174         }
175 
176     /**
177      * Are location changes currently blocked?
178      */
179     private val blockLocationChanges: Boolean
180         get() {
181             return goingToSleep || dozeAnimationRunning
182         }
183 
184     /**
185      * Are we currently going to sleep
186      */
187     private var goingToSleep: Boolean = false
188         set(value) {
189             if (field != value) {
190                 field = value
191                 if (!value) {
192                     updateDesiredLocation()
193                 }
194             }
195         }
196 
197     /**
198      * Are we currently fullyAwake
199      */
200     private var fullyAwake: Boolean = false
201         set(value) {
202             if (field != value) {
203                 field = value
204                 if (value) {
205                     updateDesiredLocation(forceNoAnimation = true)
206                 }
207             }
208         }
209 
210     /**
211      * Is the doze animation currently Running
212      */
213     private var dozeAnimationRunning: Boolean = false
214         private set(value) {
215             if (field != value) {
216                 field = value
217                 if (!value) {
218                     updateDesiredLocation()
219                 }
220             }
221         }
222 
223     init {
224         statusBarStateController.addCallback(object : StatusBarStateController.StateListener {
onStatePreChangenull225             override fun onStatePreChange(oldState: Int, newState: Int) {
226                 // We're updating the location before the state change happens, since we want the
227                 // location of the previous state to still be up to date when the animation starts
228                 statusbarState = newState
229                 updateDesiredLocation()
230             }
231 
onStateChangednull232             override fun onStateChanged(newState: Int) {
233                 updateTargetState()
234             }
235 
onDozeAmountChangednull236             override fun onDozeAmountChanged(linear: Float, eased: Float) {
237                 dozeAnimationRunning = linear != 0.0f && linear != 1.0f
238             }
239 
onDozingChangednull240             override fun onDozingChanged(isDozing: Boolean) {
241                 if (!isDozing) {
242                     dozeAnimationRunning = false
243                 } else {
244                     updateDesiredLocation()
245                 }
246             }
247         })
248 
249         wakefulnessLifecycle.addObserver(object : WakefulnessLifecycle.Observer {
onFinishedGoingToSleepnull250             override fun onFinishedGoingToSleep() {
251                 goingToSleep = false
252             }
253 
onStartedGoingToSleepnull254             override fun onStartedGoingToSleep() {
255                 goingToSleep = true
256                 fullyAwake = false
257             }
258 
onFinishedWakingUpnull259             override fun onFinishedWakingUp() {
260                 goingToSleep = false
261                 fullyAwake = true
262             }
263 
onStartedWakingUpnull264             override fun onStartedWakingUp() {
265                 goingToSleep = false
266             }
267         })
268     }
269 
270     /**
271      * Register a media host and create a view can be attached to a view hierarchy
272      * and where the players will be placed in when the host is the currently desired state.
273      *
274      * @return the hostView associated with this location
275      */
registernull276     fun register(mediaObject: MediaHost): UniqueObjectHostView {
277         val viewHost = createUniqueObjectHost()
278         mediaObject.hostView = viewHost
279         mediaObject.addVisibilityChangeListener {
280             // Never animate because of a visibility change, only state changes should do that
281             updateDesiredLocation(forceNoAnimation = true)
282         }
283         mediaHosts[mediaObject.location] = mediaObject
284         if (mediaObject.location == desiredLocation) {
285             // In case we are overriding a view that is already visible, make sure we attach it
286             // to this new host view in the below call
287             desiredLocation = -1
288         }
289         if (mediaObject.location == currentAttachmentLocation) {
290             currentAttachmentLocation = -1
291         }
292         updateDesiredLocation()
293         return viewHost
294     }
295 
createUniqueObjectHostnull296     private fun createUniqueObjectHost(): UniqueObjectHostView {
297         val viewHost = UniqueObjectHostView(context)
298         viewHost.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
299             override fun onViewAttachedToWindow(p0: View?) {
300                 if (rootOverlay == null) {
301                     rootView = viewHost.viewRootImpl.view
302                     rootOverlay = (rootView!!.overlay as ViewGroupOverlay)
303                 }
304                 viewHost.removeOnAttachStateChangeListener(this)
305             }
306 
307             override fun onViewDetachedFromWindow(p0: View?) {
308             }
309         })
310         return viewHost
311     }
312 
313     /**
314      * Updates the location that the view should be in. If it changes, an animation may be triggered
315      * going from the old desired location to the new one.
316      *
317      * @param forceNoAnimation optional parameter telling the system not to animate
318      */
updateDesiredLocationnull319     private fun updateDesiredLocation(forceNoAnimation: Boolean = false) {
320         val desiredLocation = calculateLocation()
321         if (desiredLocation != this.desiredLocation) {
322             if (this.desiredLocation >= 0) {
323                 previousLocation = this.desiredLocation
324             }
325             val isNewView = this.desiredLocation == -1
326             this.desiredLocation = desiredLocation
327             // Let's perform a transition
328             val animate = !forceNoAnimation &&
329                     shouldAnimateTransition(desiredLocation, previousLocation)
330             val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation)
331             val host = getHost(desiredLocation)
332             mediaCarouselController.onDesiredLocationChanged(desiredLocation, host, animate,
333                     animDuration, delay)
334             performTransitionToNewLocation(isNewView, animate)
335         }
336     }
337 
performTransitionToNewLocationnull338     private fun performTransitionToNewLocation(isNewView: Boolean, animate: Boolean) {
339         if (previousLocation < 0 || isNewView) {
340             cancelAnimationAndApplyDesiredState()
341             return
342         }
343         val currentHost = getHost(desiredLocation)
344         val previousHost = getHost(previousLocation)
345         if (currentHost == null || previousHost == null) {
346             cancelAnimationAndApplyDesiredState()
347             return
348         }
349         updateTargetState()
350         if (isCurrentlyInGuidedTransformation()) {
351             applyTargetStateIfNotAnimating()
352         } else if (animate) {
353             animator.cancel()
354             if (currentAttachmentLocation != previousLocation ||
355                     !previousHost.hostView.isAttachedToWindow) {
356                 // Let's animate to the new position, starting from the current position
357                 // We also go in here in case the view was detached, since the bounds wouldn't
358                 // be correct anymore
359                 animationStartBounds.set(currentBounds)
360             } else {
361                 // otherwise, let's take the freshest state, since the current one could
362                 // be outdated
363                 animationStartBounds.set(previousHost.currentBounds)
364             }
365             adjustAnimatorForTransition(desiredLocation, previousLocation)
366             if (!animationPending) {
367                 rootView?.let {
368                     // Let's delay the animation start until we finished laying out
369                     animationPending = true
370                     it.postOnAnimation(startAnimation)
371                 }
372             }
373         } else {
374             cancelAnimationAndApplyDesiredState()
375         }
376     }
377 
shouldAnimateTransitionnull378     private fun shouldAnimateTransition(
379         @MediaLocation currentLocation: Int,
380         @MediaLocation previousLocation: Int
381     ): Boolean {
382         if (isCurrentlyInGuidedTransformation()) {
383             return false
384         }
385         if (currentLocation == LOCATION_QQS &&
386                 previousLocation == LOCATION_LOCKSCREEN &&
387                 (statusBarStateController.leaveOpenOnKeyguardHide() ||
388                         statusbarState == StatusBarState.SHADE_LOCKED)) {
389             // Usually listening to the isShown is enough to determine this, but there is some
390             // non-trivial reattaching logic happening that will make the view not-shown earlier
391             return true
392         }
393         return mediaFrame.isShownNotFaded || animator.isRunning || animationPending
394     }
395 
adjustAnimatorForTransitionnull396     private fun adjustAnimatorForTransition(desiredLocation: Int, previousLocation: Int) {
397         val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation)
398         animator.apply {
399             duration = animDuration
400             startDelay = delay
401         }
402     }
403 
getAnimationParamsnull404     private fun getAnimationParams(previousLocation: Int, desiredLocation: Int): Pair<Long, Long> {
405         var animDuration = 200L
406         var delay = 0L
407         if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) {
408             // Going to the full shade, let's adjust the animation duration
409             if (statusbarState == StatusBarState.SHADE &&
410                     keyguardStateController.isKeyguardFadingAway) {
411                 delay = keyguardStateController.keyguardFadingAwayDelay
412             }
413             animDuration = StackStateAnimator.ANIMATION_DURATION_GO_TO_FULL_SHADE.toLong()
414         } else if (previousLocation == LOCATION_QQS && desiredLocation == LOCATION_LOCKSCREEN) {
415             animDuration = StackStateAnimator.ANIMATION_DURATION_APPEAR_DISAPPEAR.toLong()
416         }
417         return animDuration to delay
418     }
419 
applyTargetStateIfNotAnimatingnull420     private fun applyTargetStateIfNotAnimating() {
421         if (!animator.isRunning) {
422             // Let's immediately apply the target state (which is interpolated) if there is
423             // no animation running. Otherwise the animation update will already update
424             // the location
425             applyState(targetBounds)
426         }
427     }
428 
429     /**
430      * Updates the bounds that the view wants to be in at the end of the animation.
431      */
updateTargetStatenull432     private fun updateTargetState() {
433         if (isCurrentlyInGuidedTransformation()) {
434             val progress = getTransformationProgress()
435             var endHost = getHost(desiredLocation)!!
436             var starthost = getHost(previousLocation)!!
437             // If either of the hosts are invisible, let's keep them at the other host location to
438             // have a nicer disappear animation. Otherwise the currentBounds of the state might
439             // be undefined
440             if (!endHost.visible) {
441                 endHost = starthost
442             } else if (!starthost.visible) {
443                 starthost = endHost
444             }
445             val newBounds = endHost.currentBounds
446             val previousBounds = starthost.currentBounds
447             targetBounds = interpolateBounds(previousBounds, newBounds, progress)
448         } else {
449             val bounds = getHost(desiredLocation)?.currentBounds ?: return
450             targetBounds.set(bounds)
451         }
452     }
453 
interpolateBoundsnull454     private fun interpolateBounds(
455         startBounds: Rect,
456         endBounds: Rect,
457         progress: Float,
458         result: Rect? = null
459     ): Rect {
460         val left = MathUtils.lerp(startBounds.left.toFloat(),
461                 endBounds.left.toFloat(), progress).toInt()
462         val top = MathUtils.lerp(startBounds.top.toFloat(),
463                 endBounds.top.toFloat(), progress).toInt()
464         val right = MathUtils.lerp(startBounds.right.toFloat(),
465                 endBounds.right.toFloat(), progress).toInt()
466         val bottom = MathUtils.lerp(startBounds.bottom.toFloat(),
467                 endBounds.bottom.toFloat(), progress).toInt()
468         val resultBounds = result ?: Rect()
469         resultBounds.set(left, top, right, bottom)
470         return resultBounds
471     }
472 
473     /**
474      * @return true if this transformation is guided by an external progress like a finger
475      */
isCurrentlyInGuidedTransformationnull476     private fun isCurrentlyInGuidedTransformation(): Boolean {
477         return getTransformationProgress() >= 0
478     }
479 
480     /**
481      * @return the current transformation progress if we're in a guided transformation and -1
482      * otherwise
483      */
getTransformationProgressnull484     private fun getTransformationProgress(): Float {
485         val progress = getQSTransformationProgress()
486         if (progress >= 0) {
487             return progress
488         }
489         return -1.0f
490     }
491 
getQSTransformationProgressnull492     private fun getQSTransformationProgress(): Float {
493         val currentHost = getHost(desiredLocation)
494         val previousHost = getHost(previousLocation)
495         if (currentHost?.location == LOCATION_QS) {
496             if (previousHost?.location == LOCATION_QQS) {
497                 if (previousHost.visible || statusbarState != StatusBarState.KEYGUARD) {
498                     return qsExpansion
499                 }
500             }
501         }
502         return -1.0f
503     }
504 
getHostnull505     private fun getHost(@MediaLocation location: Int): MediaHost? {
506         if (location < 0) {
507             return null
508         }
509         return mediaHosts[location]
510     }
511 
cancelAnimationAndApplyDesiredStatenull512     private fun cancelAnimationAndApplyDesiredState() {
513         animator.cancel()
514         getHost(desiredLocation)?.let {
515             applyState(it.currentBounds, immediately = true)
516         }
517     }
518 
519     /**
520      * Apply the current state to the view, updating it's bounds and desired state
521      */
applyStatenull522     private fun applyState(bounds: Rect, immediately: Boolean = false) {
523         currentBounds.set(bounds)
524         val currentlyInGuidedTransformation = isCurrentlyInGuidedTransformation()
525         val startLocation = if (currentlyInGuidedTransformation) previousLocation else -1
526         val progress = if (currentlyInGuidedTransformation) getTransformationProgress() else 1.0f
527         val endLocation = desiredLocation
528         mediaCarouselController.setCurrentState(startLocation, endLocation, progress, immediately)
529         updateHostAttachment()
530         if (currentAttachmentLocation == IN_OVERLAY) {
531             mediaFrame.setLeftTopRightBottom(
532                     currentBounds.left,
533                     currentBounds.top,
534                     currentBounds.right,
535                     currentBounds.bottom)
536         }
537     }
538 
updateHostAttachmentnull539     private fun updateHostAttachment() {
540         val inOverlay = isTransitionRunning() && rootOverlay != null
541         val newLocation = if (inOverlay) IN_OVERLAY else desiredLocation
542         if (currentAttachmentLocation != newLocation) {
543             currentAttachmentLocation = newLocation
544 
545             // Remove the carousel from the old host
546             (mediaFrame.parent as ViewGroup?)?.removeView(mediaFrame)
547 
548             // Add it to the new one
549             val targetHost = getHost(desiredLocation)!!.hostView
550             if (inOverlay) {
551                 rootOverlay!!.add(mediaFrame)
552             } else {
553                 // When adding back to the host, let's make sure to reset the bounds.
554                 // Usually adding the view will trigger a layout that does this automatically,
555                 // but we sometimes suppress this.
556                 targetHost.addView(mediaFrame)
557                 val left = targetHost.paddingLeft
558                 val top = targetHost.paddingTop
559                 mediaFrame.setLeftTopRightBottom(
560                         left,
561                         top,
562                         left + currentBounds.width(),
563                         top + currentBounds.height())
564             }
565         }
566     }
567 
isTransitionRunningnull568     private fun isTransitionRunning(): Boolean {
569         return isCurrentlyInGuidedTransformation() && getTransformationProgress() != 1.0f ||
570                 animator.isRunning || animationPending
571     }
572 
573     @MediaLocation
calculateLocationnull574     private fun calculateLocation(): Int {
575         if (blockLocationChanges) {
576             // Keep the current location until we're allowed to again
577             return desiredLocation
578         }
579         val onLockscreen = (!bypassController.bypassEnabled &&
580                 (statusbarState == StatusBarState.KEYGUARD ||
581                         statusbarState == StatusBarState.FULLSCREEN_USER_SWITCHER))
582         val allowedOnLockscreen = notifLockscreenUserManager.shouldShowLockscreenNotifications()
583         val location = when {
584             qsExpansion > 0.0f && !onLockscreen -> LOCATION_QS
585             qsExpansion > 0.4f && onLockscreen -> LOCATION_QS
586             onLockscreen && allowedOnLockscreen -> LOCATION_LOCKSCREEN
587             else -> LOCATION_QQS
588         }
589         // When we're on lock screen and the player is not active, we should keep it in QS.
590         // Otherwise it will try to animate a transition that doesn't make sense.
591         if (location == LOCATION_LOCKSCREEN && getHost(location)?.visible != true &&
592                 !statusBarStateController.isDozing) {
593             return LOCATION_QS
594         }
595         if (location == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QS &&
596                 collapsingShadeFromQS) {
597             // When collapsing on the lockscreen, we want to remain in QS
598             return LOCATION_QS
599         }
600         if (location != LOCATION_LOCKSCREEN && desiredLocation == LOCATION_LOCKSCREEN
601                 && !fullyAwake) {
602             // When unlocking from dozing / while waking up, the media shouldn't be transitioning
603             // in an animated way. Let's keep it in the lockscreen until we're fully awake and
604             // reattach it without an animation
605             return LOCATION_LOCKSCREEN
606         }
607         return location
608     }
609 
610     companion object {
611         /**
612          * Attached in expanded quick settings
613          */
614         const val LOCATION_QS = 0
615 
616         /**
617          * Attached in the collapsed QS
618          */
619         const val LOCATION_QQS = 1
620 
621         /**
622          * Attached on the lock screen
623          */
624         const val LOCATION_LOCKSCREEN = 2
625 
626         /**
627          * Attached at the root of the hierarchy in an overlay
628          */
629         const val IN_OVERLAY = -1000
630     }
631 }
632 
633 @IntDef(prefix = ["LOCATION_"], value = [MediaHierarchyManager.LOCATION_QS,
634     MediaHierarchyManager.LOCATION_QQS, MediaHierarchyManager.LOCATION_LOCKSCREEN])
635 @Retention(AnnotationRetention.SOURCE)
636 annotation class MediaLocation