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.app.animation.Interpolators 37 import com.android.keyguard.KeyguardViewController 38 import com.android.systemui.R 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 = 154 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 204 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 234 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 updateUserVisibility() 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 */ 311 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 */ 322 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 */ 426 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 { 438 override fun onConfigChanged(newConfig: Configuration?) { 439 updateConfiguration() 440 updateDesiredLocation(forceNoAnimation = true, forceStateUpdate = true) 441 } 442 } 443 ) 444 statusBarStateController.addCallback( 445 object : StatusBarStateController.StateListener { 446 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 455 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 updateUserVisibility() 464 } 465 466 override fun onDozeAmountChanged(linear: Float, eased: Float) { 467 dozeAnimationRunning = linear != 0.0f && linear != 1.0f 468 } 469 470 override fun onDozingChanged(isDozing: Boolean) { 471 if (!isDozing) { 472 dozeAnimationRunning = false 473 // Enters lock screen from screen off 474 if (isLockScreenVisibleToUser()) { 475 mediaCarouselController.logSmartspaceImpression(qsExpanded) 476 } 477 } else { 478 updateDesiredLocation() 479 qsExpanded = false 480 closeGuts() 481 } 482 updateUserVisibility() 483 } 484 485 override fun onExpandedChanged(isExpanded: Boolean) { 486 // Enters shade from home screen 487 if (isHomeScreenShadeVisibleToUser()) { 488 mediaCarouselController.logSmartspaceImpression(qsExpanded) 489 } 490 updateUserVisibility() 491 } 492 } 493 ) 494 495 dreamOverlayStateController.addCallback( 496 object : DreamOverlayStateController.Callback { 497 override fun onComplicationsChanged() { 498 dreamMediaComplicationActive = 499 dreamOverlayStateController.complications.any { 500 it is MediaDreamComplication 501 } 502 } 503 504 override fun onStateChanged() { 505 dreamOverlayStateController.isOverlayActive.also { dreamOverlayActive = it } 506 } 507 } 508 ) 509 510 wakefulnessLifecycle.addObserver( 511 object : WakefulnessLifecycle.Observer { 512 override fun onFinishedGoingToSleep() { 513 goingToSleep = false 514 } 515 516 override fun onStartedGoingToSleep() { 517 goingToSleep = true 518 fullyAwake = false 519 } 520 521 override fun onFinishedWakingUp() { 522 goingToSleep = false 523 fullyAwake = true 524 } 525 526 override fun onStartedWakingUp() { 527 goingToSleep = false 528 } 529 } 530 ) 531 532 mediaCarouselController.updateUserVisibility = this::updateUserVisibility 533 mediaCarouselController.updateHostVisibility = { 534 mediaHosts.forEach { it?.updateViewVisibility() } 535 } 536 537 panelEventsEvents.addShadeStateEventsListener( 538 object : ShadeStateEventsListener { 539 override fun onExpandImmediateChanged(isExpandImmediateEnabled: Boolean) { 540 skipQqsOnExpansion = isExpandImmediateEnabled 541 updateDesiredLocation() 542 } 543 } 544 ) 545 546 val settingsObserver: ContentObserver = 547 object : ContentObserver(handler) { 548 override fun onChange(selfChange: Boolean, uri: Uri?) { 549 if (uri == lockScreenMediaPlayerUri) { 550 allowMediaPlayerOnLockScreen = 551 secureSettings.getBoolForUser( 552 Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN, 553 true, 554 UserHandle.USER_CURRENT 555 ) 556 } 557 } 558 } 559 secureSettings.registerContentObserverForUser( 560 Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN, 561 settingsObserver, 562 UserHandle.USER_ALL 563 ) 564 } 565 566 private fun updateConfiguration() { 567 distanceForFullShadeTransition = 568 context.resources.getDimensionPixelSize( 569 R.dimen.lockscreen_shade_media_transition_distance 570 ) 571 inSplitShade = LargeScreenUtils.shouldUseSplitNotificationShade(context.resources) 572 } 573 574 /** 575 * Register a media host and create a view can be attached to a view hierarchy and where the 576 * players will be placed in when the host is the currently desired state. 577 * 578 * @return the hostView associated with this location 579 */ 580 fun register(mediaObject: MediaHost): UniqueObjectHostView { 581 val viewHost = createUniqueObjectHost() 582 mediaObject.hostView = viewHost 583 mediaObject.addVisibilityChangeListener { 584 // Never animate because of a visibility change, only state changes should do that 585 updateDesiredLocation(forceNoAnimation = true) 586 } 587 mediaHosts[mediaObject.location] = mediaObject 588 if (mediaObject.location == desiredLocation) { 589 // In case we are overriding a view that is already visible, make sure we attach it 590 // to this new host view in the below call 591 desiredLocation = -1 592 } 593 if (mediaObject.location == currentAttachmentLocation) { 594 currentAttachmentLocation = -1 595 } 596 updateDesiredLocation() 597 return viewHost 598 } 599 600 /** Close the guts in all players in [MediaCarouselController]. */ 601 fun closeGuts() { 602 mediaCarouselController.closeGuts() 603 } 604 605 private fun createUniqueObjectHost(): UniqueObjectHostView { 606 val viewHost = UniqueObjectHostView(context) 607 viewHost.addOnAttachStateChangeListener( 608 object : View.OnAttachStateChangeListener { 609 override fun onViewAttachedToWindow(p0: View) { 610 if (rootOverlay == null) { 611 rootView = viewHost.viewRootImpl.view 612 rootOverlay = (rootView!!.overlay as ViewGroupOverlay) 613 } 614 viewHost.removeOnAttachStateChangeListener(this) 615 } 616 617 override fun onViewDetachedFromWindow(p0: View) {} 618 } 619 ) 620 return viewHost 621 } 622 623 /** 624 * Updates the location that the view should be in. If it changes, an animation may be triggered 625 * going from the old desired location to the new one. 626 * 627 * @param forceNoAnimation optional parameter telling the system not to animate 628 * @param forceStateUpdate optional parameter telling the system to update transition state 629 * 630 * ``` 631 * even if location did not change 632 * ``` 633 */ 634 private fun updateDesiredLocation( 635 forceNoAnimation: Boolean = false, 636 forceStateUpdate: Boolean = false 637 ) = 638 traceSection("MediaHierarchyManager#updateDesiredLocation") { 639 val desiredLocation = calculateLocation() 640 if ( 641 desiredLocation != this.desiredLocation || forceStateUpdate && !blockLocationChanges 642 ) { 643 if (this.desiredLocation >= 0 && desiredLocation != this.desiredLocation) { 644 // Only update previous location when it actually changes 645 previousLocation = this.desiredLocation 646 } else if (forceStateUpdate) { 647 val onLockscreen = 648 (!bypassController.bypassEnabled && 649 (statusbarState == StatusBarState.KEYGUARD)) 650 if ( 651 desiredLocation == LOCATION_QS && 652 previousLocation == LOCATION_LOCKSCREEN && 653 !onLockscreen 654 ) { 655 // If media active state changed and the device is now unlocked, update the 656 // previous location so we animate between the correct hosts 657 previousLocation = LOCATION_QQS 658 } 659 } 660 val isNewView = this.desiredLocation == -1 661 this.desiredLocation = desiredLocation 662 // Let's perform a transition 663 val animate = 664 !forceNoAnimation && shouldAnimateTransition(desiredLocation, previousLocation) 665 val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation) 666 val host = getHost(desiredLocation) 667 val willFade = calculateTransformationType() == TRANSFORMATION_TYPE_FADE 668 if (!willFade || isCurrentlyInGuidedTransformation() || !animate) { 669 // if we're fading, we want the desired location / measurement only to change 670 // once fully faded. This is happening in the host attachment 671 mediaCarouselController.onDesiredLocationChanged( 672 desiredLocation, 673 host, 674 animate, 675 animDuration, 676 delay 677 ) 678 } 679 performTransitionToNewLocation(isNewView, animate) 680 } 681 } 682 683 private fun performTransitionToNewLocation(isNewView: Boolean, animate: Boolean) = 684 traceSection("MediaHierarchyManager#performTransitionToNewLocation") { 685 if (previousLocation < 0 || isNewView) { 686 cancelAnimationAndApplyDesiredState() 687 return 688 } 689 val currentHost = getHost(desiredLocation) 690 val previousHost = getHost(previousLocation) 691 if (currentHost == null || previousHost == null) { 692 cancelAnimationAndApplyDesiredState() 693 return 694 } 695 updateTargetState() 696 if (isCurrentlyInGuidedTransformation()) { 697 applyTargetStateIfNotAnimating() 698 } else if (animate) { 699 val wasCrossFading = isCrossFadeAnimatorRunning 700 val previewsCrossFadeProgress = animationCrossFadeProgress 701 animator.cancel() 702 if ( 703 currentAttachmentLocation != previousLocation || 704 !previousHost.hostView.isAttachedToWindow 705 ) { 706 // Let's animate to the new position, starting from the current position 707 // We also go in here in case the view was detached, since the bounds wouldn't 708 // be correct anymore 709 animationStartBounds.set(currentBounds) 710 animationStartClipping.set(currentClipping) 711 } else { 712 // otherwise, let's take the freshest state, since the current one could 713 // be outdated 714 animationStartBounds.set(previousHost.currentBounds) 715 animationStartClipping.set(previousHost.currentClipping) 716 } 717 val transformationType = calculateTransformationType() 718 var needsCrossFade = transformationType == TRANSFORMATION_TYPE_FADE 719 var crossFadeStartProgress = 0.0f 720 // The alpha is only relevant when not cross fading 721 var newCrossFadeStartLocation = previousLocation 722 if (wasCrossFading) { 723 if (currentAttachmentLocation == crossFadeAnimationEndLocation) { 724 if (needsCrossFade) { 725 // We were previously crossFading and we've already reached 726 // the end view, Let's start crossfading from the same position there 727 crossFadeStartProgress = 1.0f - previewsCrossFadeProgress 728 } 729 // Otherwise let's fade in from the current alpha, but not cross fade 730 } else { 731 // We haven't reached the previous location yet, let's still cross fade from 732 // where we were. 733 newCrossFadeStartLocation = crossFadeAnimationStartLocation 734 if (newCrossFadeStartLocation == desiredLocation) { 735 // we're crossFading back to where we were, let's start at the end 736 // position 737 crossFadeStartProgress = 1.0f - previewsCrossFadeProgress 738 } else { 739 // Let's start from where we are right now 740 crossFadeStartProgress = previewsCrossFadeProgress 741 // We need to force cross fading as we haven't reached the end location 742 // yet 743 needsCrossFade = true 744 } 745 } 746 } else if (needsCrossFade) { 747 // let's not flicker and start with the same alpha 748 crossFadeStartProgress = (1.0f - carouselAlpha) / 2.0f 749 } 750 isCrossFadeAnimatorRunning = needsCrossFade 751 crossFadeAnimationStartLocation = newCrossFadeStartLocation 752 crossFadeAnimationEndLocation = desiredLocation 753 animationStartAlpha = carouselAlpha 754 animationStartCrossFadeProgress = crossFadeStartProgress 755 adjustAnimatorForTransition(desiredLocation, previousLocation) 756 if (!animationPending) { 757 rootView?.let { 758 // Let's delay the animation start until we finished laying out 759 animationPending = true 760 it.postOnAnimation(startAnimation) 761 } 762 } 763 } else { 764 cancelAnimationAndApplyDesiredState() 765 } 766 } 767 768 private fun shouldAnimateTransition( 769 @MediaLocation currentLocation: Int, 770 @MediaLocation previousLocation: Int 771 ): Boolean { 772 if (isCurrentlyInGuidedTransformation()) { 773 return false 774 } 775 if (skipQqsOnExpansion) { 776 return false 777 } 778 // This is an invalid transition, and can happen when using the camera gesture from the 779 // lock screen. Disallow. 780 if ( 781 previousLocation == LOCATION_LOCKSCREEN && 782 desiredLocation == LOCATION_QQS && 783 statusbarState == StatusBarState.SHADE 784 ) { 785 return false 786 } 787 788 if ( 789 currentLocation == LOCATION_QQS && 790 previousLocation == LOCATION_LOCKSCREEN && 791 (statusBarStateController.leaveOpenOnKeyguardHide() || 792 statusbarState == StatusBarState.SHADE_LOCKED) 793 ) { 794 // Usually listening to the isShown is enough to determine this, but there is some 795 // non-trivial reattaching logic happening that will make the view not-shown earlier 796 return true 797 } 798 799 if ( 800 desiredLocation == LOCATION_QS && 801 previousLocation == LOCATION_LOCKSCREEN && 802 statusbarState == StatusBarState.SHADE 803 ) { 804 // This is an invalid transition, can happen when tapping on home control and the UMO 805 // while being on landscape orientation in tablet. 806 return false 807 } 808 809 if ( 810 statusbarState == StatusBarState.KEYGUARD && 811 (currentLocation == LOCATION_LOCKSCREEN || previousLocation == LOCATION_LOCKSCREEN) 812 ) { 813 // We're always fading from lockscreen to keyguard in situations where the player 814 // is already fully hidden 815 return false 816 } 817 return mediaFrame.isShownNotFaded || animator.isRunning || animationPending 818 } 819 820 private fun adjustAnimatorForTransition(desiredLocation: Int, previousLocation: Int) { 821 val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation) 822 animator.apply { 823 duration = animDuration 824 startDelay = delay 825 } 826 } 827 828 private fun getAnimationParams(previousLocation: Int, desiredLocation: Int): Pair<Long, Long> { 829 var animDuration = 200L 830 var delay = 0L 831 if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) { 832 // Going to the full shade, let's adjust the animation duration 833 if ( 834 statusbarState == StatusBarState.SHADE && 835 keyguardStateController.isKeyguardFadingAway 836 ) { 837 delay = keyguardStateController.keyguardFadingAwayDelay 838 } 839 animDuration = (StackStateAnimator.ANIMATION_DURATION_GO_TO_FULL_SHADE / 2f).toLong() 840 } else if (previousLocation == LOCATION_QQS && desiredLocation == LOCATION_LOCKSCREEN) { 841 animDuration = StackStateAnimator.ANIMATION_DURATION_APPEAR_DISAPPEAR.toLong() 842 } 843 return animDuration to delay 844 } 845 846 private fun applyTargetStateIfNotAnimating() { 847 if (!animator.isRunning) { 848 // Let's immediately apply the target state (which is interpolated) if there is 849 // no animation running. Otherwise the animation update will already update 850 // the location 851 applyState(targetBounds, carouselAlpha, clipBounds = targetClipping) 852 } 853 } 854 855 /** Updates the bounds that the view wants to be in at the end of the animation. */ 856 private fun updateTargetState() { 857 var starthost = getHost(previousLocation) 858 var endHost = getHost(desiredLocation) 859 if ( 860 isCurrentlyInGuidedTransformation() && 861 !isCurrentlyFading() && 862 starthost != null && 863 endHost != null 864 ) { 865 val progress = getTransformationProgress() 866 // If either of the hosts are invisible, let's keep them at the other host location to 867 // have a nicer disappear animation. Otherwise the currentBounds of the state might 868 // be undefined 869 if (!endHost.visible) { 870 endHost = starthost 871 } else if (!starthost.visible) { 872 starthost = endHost 873 } 874 val newBounds = endHost.currentBounds 875 val previousBounds = starthost.currentBounds 876 targetBounds = interpolateBounds(previousBounds, newBounds, progress) 877 targetClipping = endHost.currentClipping 878 } else if (endHost != null) { 879 val bounds = endHost.currentBounds 880 targetBounds.set(bounds) 881 targetClipping = endHost.currentClipping 882 } 883 } 884 885 private fun interpolateBounds( 886 startBounds: Rect, 887 endBounds: Rect, 888 progress: Float, 889 result: Rect? = null 890 ): Rect { 891 val left = 892 MathUtils.lerp(startBounds.left.toFloat(), endBounds.left.toFloat(), progress).toInt() 893 val top = 894 MathUtils.lerp(startBounds.top.toFloat(), endBounds.top.toFloat(), progress).toInt() 895 val right = 896 MathUtils.lerp(startBounds.right.toFloat(), endBounds.right.toFloat(), progress).toInt() 897 val bottom = 898 MathUtils.lerp(startBounds.bottom.toFloat(), endBounds.bottom.toFloat(), progress) 899 .toInt() 900 val resultBounds = result ?: Rect() 901 resultBounds.set(left, top, right, bottom) 902 return resultBounds 903 } 904 905 /** @return true if this transformation is guided by an external progress like a finger */ 906 fun isCurrentlyInGuidedTransformation(): Boolean { 907 return hasValidStartAndEndLocations() && 908 getTransformationProgress() >= 0 && 909 (areGuidedTransitionHostsVisible() || !hasActiveMediaOrRecommendation) 910 } 911 912 private fun hasValidStartAndEndLocations(): Boolean { 913 return previousLocation != -1 && desiredLocation != -1 914 } 915 916 /** Calculate the transformation type for the current animation */ 917 @VisibleForTesting 918 @TransformationType 919 fun calculateTransformationType(): Int { 920 if (isTransitioningToFullShade) { 921 if (inSplitShade && areGuidedTransitionHostsVisible()) { 922 return TRANSFORMATION_TYPE_TRANSITION 923 } 924 return TRANSFORMATION_TYPE_FADE 925 } 926 if ( 927 previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QS || 928 previousLocation == LOCATION_QS && desiredLocation == LOCATION_LOCKSCREEN 929 ) { 930 // animating between ls and qs should fade, as QS is clipped. 931 return TRANSFORMATION_TYPE_FADE 932 } 933 if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) { 934 // animating between ls and qqs should fade when dragging down via e.g. expand button 935 return TRANSFORMATION_TYPE_FADE 936 } 937 return TRANSFORMATION_TYPE_TRANSITION 938 } 939 940 private fun areGuidedTransitionHostsVisible(): Boolean { 941 return getHost(previousLocation)?.visible == true && 942 getHost(desiredLocation)?.visible == true 943 } 944 945 /** 946 * @return the current transformation progress if we're in a guided transformation and -1 947 * otherwise 948 */ 949 private fun getTransformationProgress(): Float { 950 if (skipQqsOnExpansion) { 951 return -1.0f 952 } 953 val progress = getQSTransformationProgress() 954 if (statusbarState != StatusBarState.KEYGUARD && progress >= 0) { 955 return progress 956 } 957 if (isTransitioningToFullShade) { 958 return fullShadeTransitionProgress 959 } 960 return -1.0f 961 } 962 963 private fun getQSTransformationProgress(): Float { 964 val currentHost = getHost(desiredLocation) 965 val previousHost = getHost(previousLocation) 966 if (currentHost?.location == LOCATION_QS && !inSplitShade) { 967 if (previousHost?.location == LOCATION_QQS) { 968 if (previousHost.visible || statusbarState != StatusBarState.KEYGUARD) { 969 return qsExpansion 970 } 971 } 972 } 973 return -1.0f 974 } 975 976 private fun getHost(@MediaLocation location: Int): MediaHost? { 977 if (location < 0) { 978 return null 979 } 980 return mediaHosts[location] 981 } 982 983 private fun cancelAnimationAndApplyDesiredState() { 984 animator.cancel() 985 getHost(desiredLocation)?.let { 986 applyState(it.currentBounds, alpha = 1.0f, immediately = true) 987 } 988 } 989 990 /** Apply the current state to the view, updating it's bounds and desired state */ 991 private fun applyState( 992 bounds: Rect, 993 alpha: Float, 994 immediately: Boolean = false, 995 clipBounds: Rect = EMPTY_RECT 996 ) = 997 traceSection("MediaHierarchyManager#applyState") { 998 currentBounds.set(bounds) 999 currentClipping = clipBounds 1000 carouselAlpha = if (isCurrentlyFading()) alpha else 1.0f 1001 val onlyUseEndState = !isCurrentlyInGuidedTransformation() || isCurrentlyFading() 1002 val startLocation = if (onlyUseEndState) -1 else previousLocation 1003 val progress = if (onlyUseEndState) 1.0f else getTransformationProgress() 1004 val endLocation = resolveLocationForFading() 1005 mediaCarouselController.setCurrentState( 1006 startLocation, 1007 endLocation, 1008 progress, 1009 immediately 1010 ) 1011 updateHostAttachment() 1012 if (currentAttachmentLocation == IN_OVERLAY) { 1013 // Setting the clipping on the hierarchy of `mediaFrame` does not work 1014 if (!currentClipping.isEmpty) { 1015 currentBounds.intersect(currentClipping) 1016 } 1017 mediaFrame.setLeftTopRightBottom( 1018 currentBounds.left, 1019 currentBounds.top, 1020 currentBounds.right, 1021 currentBounds.bottom 1022 ) 1023 } 1024 } 1025 1026 private fun updateHostAttachment() = 1027 traceSection("MediaHierarchyManager#updateHostAttachment") { 1028 var newLocation = resolveLocationForFading() 1029 // Don't use the overlay when fading or when we don't have active media 1030 var canUseOverlay = !isCurrentlyFading() && hasActiveMediaOrRecommendation 1031 if (isCrossFadeAnimatorRunning) { 1032 if ( 1033 getHost(newLocation)?.visible == true && 1034 getHost(newLocation)?.hostView?.isShown == false && 1035 newLocation != desiredLocation 1036 ) { 1037 // We're crossfading but the view is already hidden. Let's move to the overlay 1038 // instead. This happens when animating to the full shade using a button click. 1039 canUseOverlay = true 1040 } 1041 } 1042 val inOverlay = isTransitionRunning() && rootOverlay != null && canUseOverlay 1043 newLocation = if (inOverlay) IN_OVERLAY else newLocation 1044 if (currentAttachmentLocation != newLocation) { 1045 currentAttachmentLocation = newLocation 1046 1047 // Remove the carousel from the old host 1048 (mediaFrame.parent as ViewGroup?)?.removeView(mediaFrame) 1049 1050 // Add it to the new one 1051 if (inOverlay) { 1052 rootOverlay!!.add(mediaFrame) 1053 } else { 1054 val targetHost = getHost(newLocation)!!.hostView 1055 // This will either do a full layout pass and remeasure, or it will bypass 1056 // that and directly set the mediaFrame's bounds within the premeasured host. 1057 targetHost.addView(mediaFrame) 1058 } 1059 if (isCrossFadeAnimatorRunning) { 1060 // When cross-fading with an animation, we only notify the media carousel of the 1061 // location change, once the view is reattached to the new place and not 1062 // immediately 1063 // when the desired location changes. This callback will update the measurement 1064 // of the carousel, only once we've faded out at the old location and then 1065 // reattach 1066 // to fade it in at the new location. 1067 mediaCarouselController.onDesiredLocationChanged( 1068 newLocation, 1069 getHost(newLocation), 1070 animate = false 1071 ) 1072 } 1073 } 1074 } 1075 1076 /** 1077 * Calculate the location when cross fading between locations. While fading out, the content 1078 * should remain in the previous location, while after the switch it should be at the desired 1079 * location. 1080 */ 1081 private fun resolveLocationForFading(): Int { 1082 if (isCrossFadeAnimatorRunning) { 1083 // When animating between two hosts with a fade, let's keep ourselves in the old 1084 // location for the first half, and then switch over to the end location 1085 if (animationCrossFadeProgress > 0.5 || previousLocation == -1) { 1086 return crossFadeAnimationEndLocation 1087 } else { 1088 return crossFadeAnimationStartLocation 1089 } 1090 } 1091 return desiredLocation 1092 } 1093 1094 private fun isTransitionRunning(): Boolean { 1095 return isCurrentlyInGuidedTransformation() && getTransformationProgress() != 1.0f || 1096 animator.isRunning || 1097 animationPending 1098 } 1099 1100 @MediaLocation 1101 private fun calculateLocation(): Int { 1102 if (blockLocationChanges) { 1103 // Keep the current location until we're allowed to again 1104 return desiredLocation 1105 } 1106 val onLockscreen = 1107 (!bypassController.bypassEnabled && (statusbarState == StatusBarState.KEYGUARD)) 1108 val location = 1109 when { 1110 dreamOverlayActive && dreamMediaComplicationActive -> LOCATION_DREAM_OVERLAY 1111 (qsExpansion > 0.0f || inSplitShade) && !onLockscreen -> LOCATION_QS 1112 qsExpansion > 0.4f && onLockscreen -> LOCATION_QS 1113 onLockscreen && isSplitShadeExpanding() -> LOCATION_QS 1114 onLockscreen && isTransformingToFullShadeAndInQQS() -> LOCATION_QQS 1115 onLockscreen && allowMediaPlayerOnLockScreen -> LOCATION_LOCKSCREEN 1116 else -> LOCATION_QQS 1117 } 1118 // When we're on lock screen and the player is not active, we should keep it in QS. 1119 // Otherwise it will try to animate a transition that doesn't make sense. 1120 if ( 1121 location == LOCATION_LOCKSCREEN && 1122 getHost(location)?.visible != true && 1123 !statusBarStateController.isDozing 1124 ) { 1125 return LOCATION_QS 1126 } 1127 if ( 1128 location == LOCATION_LOCKSCREEN && 1129 desiredLocation == LOCATION_QS && 1130 collapsingShadeFromQS 1131 ) { 1132 // When collapsing on the lockscreen, we want to remain in QS 1133 return LOCATION_QS 1134 } 1135 if ( 1136 location != LOCATION_LOCKSCREEN && desiredLocation == LOCATION_LOCKSCREEN && !fullyAwake 1137 ) { 1138 // When unlocking from dozing / while waking up, the media shouldn't be transitioning 1139 // in an animated way. Let's keep it in the lockscreen until we're fully awake and 1140 // reattach it without an animation 1141 return LOCATION_LOCKSCREEN 1142 } 1143 if (skipQqsOnExpansion) { 1144 // When doing an immediate expand or collapse, we want to keep it in QS. 1145 return LOCATION_QS 1146 } 1147 return location 1148 } 1149 1150 private fun isSplitShadeExpanding(): Boolean { 1151 return inSplitShade && isTransitioningToFullShade 1152 } 1153 1154 /** Are we currently transforming to the full shade and already in QQS */ 1155 private fun isTransformingToFullShadeAndInQQS(): Boolean { 1156 if (!isTransitioningToFullShade) { 1157 return false 1158 } 1159 if (inSplitShade) { 1160 // Split shade doesn't use QQS. 1161 return false 1162 } 1163 return fullShadeTransitionProgress > 0.5f 1164 } 1165 1166 /** Is the current transformationType fading */ 1167 private fun isCurrentlyFading(): Boolean { 1168 if (isSplitShadeExpanding()) { 1169 // Split shade always uses transition instead of fade. 1170 return false 1171 } 1172 if (isTransitioningToFullShade) { 1173 return true 1174 } 1175 return isCrossFadeAnimatorRunning 1176 } 1177 1178 /** Update whether or not the media carousel could be visible to the user */ 1179 private fun updateUserVisibility() { 1180 val shadeVisible = 1181 isLockScreenVisibleToUser() || 1182 isLockScreenShadeVisibleToUser() || 1183 isHomeScreenShadeVisibleToUser() 1184 val mediaVisible = qsExpanded || hasActiveMediaOrRecommendation 1185 mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = 1186 shadeVisible && mediaVisible 1187 } 1188 1189 private fun isLockScreenVisibleToUser(): Boolean { 1190 return !statusBarStateController.isDozing && 1191 !keyguardViewController.isBouncerShowing && 1192 statusBarStateController.state == StatusBarState.KEYGUARD && 1193 allowMediaPlayerOnLockScreen && 1194 statusBarStateController.isExpanded && 1195 !qsExpanded 1196 } 1197 1198 private fun isLockScreenShadeVisibleToUser(): Boolean { 1199 return !statusBarStateController.isDozing && 1200 !keyguardViewController.isBouncerShowing && 1201 (statusBarStateController.state == StatusBarState.SHADE_LOCKED || 1202 (statusBarStateController.state == StatusBarState.KEYGUARD && qsExpanded)) 1203 } 1204 1205 private fun isHomeScreenShadeVisibleToUser(): Boolean { 1206 return !statusBarStateController.isDozing && 1207 statusBarStateController.state == StatusBarState.SHADE && 1208 statusBarStateController.isExpanded 1209 } 1210 1211 companion object { 1212 /** Attached in expanded quick settings */ 1213 const val LOCATION_QS = 0 1214 1215 /** Attached in the collapsed QS */ 1216 const val LOCATION_QQS = 1 1217 1218 /** Attached on the lock screen */ 1219 const val LOCATION_LOCKSCREEN = 2 1220 1221 /** Attached on the dream overlay */ 1222 const val LOCATION_DREAM_OVERLAY = 3 1223 1224 /** Attached at the root of the hierarchy in an overlay */ 1225 const val IN_OVERLAY = -1000 1226 1227 /** 1228 * The default transformation type where the hosts transform into each other using a direct 1229 * transition 1230 */ 1231 const val TRANSFORMATION_TYPE_TRANSITION = 0 1232 1233 /** 1234 * A transformation type where content fades from one place to another instead of 1235 * transitioning 1236 */ 1237 const val TRANSFORMATION_TYPE_FADE = 1 1238 } 1239 } 1240 1241 private val EMPTY_RECT = Rect() 1242 1243 @IntDef( 1244 prefix = ["TRANSFORMATION_TYPE_"], 1245 value = 1246 [ 1247 MediaHierarchyManager.TRANSFORMATION_TYPE_TRANSITION, 1248 MediaHierarchyManager.TRANSFORMATION_TYPE_FADE 1249 ] 1250 ) 1251 @Retention(AnnotationRetention.SOURCE) 1252 private annotation class TransformationType 1253 1254 @IntDef( 1255 prefix = ["LOCATION_"], 1256 value = 1257 [ 1258 MediaHierarchyManager.LOCATION_QS, 1259 MediaHierarchyManager.LOCATION_QQS, 1260 MediaHierarchyManager.LOCATION_LOCKSCREEN, 1261 MediaHierarchyManager.LOCATION_DREAM_OVERLAY 1262 ] 1263 ) 1264 @Retention(AnnotationRetention.SOURCE) 1265 annotation class MediaLocation 1266