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 296 /** 297 * Close the guts in all players in [MediaCarouselController]. 298 */ closeGutsnull299 fun closeGuts() { 300 mediaCarouselController.closeGuts() 301 } 302 createUniqueObjectHostnull303 private fun createUniqueObjectHost(): UniqueObjectHostView { 304 val viewHost = UniqueObjectHostView(context) 305 viewHost.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { 306 override fun onViewAttachedToWindow(p0: View?) { 307 if (rootOverlay == null) { 308 rootView = viewHost.viewRootImpl.view 309 rootOverlay = (rootView!!.overlay as ViewGroupOverlay) 310 } 311 viewHost.removeOnAttachStateChangeListener(this) 312 } 313 314 override fun onViewDetachedFromWindow(p0: View?) { 315 } 316 }) 317 return viewHost 318 } 319 320 /** 321 * Updates the location that the view should be in. If it changes, an animation may be triggered 322 * going from the old desired location to the new one. 323 * 324 * @param forceNoAnimation optional parameter telling the system not to animate 325 */ updateDesiredLocationnull326 private fun updateDesiredLocation(forceNoAnimation: Boolean = false) { 327 val desiredLocation = calculateLocation() 328 if (desiredLocation != this.desiredLocation) { 329 if (this.desiredLocation >= 0) { 330 previousLocation = this.desiredLocation 331 } 332 val isNewView = this.desiredLocation == -1 333 this.desiredLocation = desiredLocation 334 // Let's perform a transition 335 val animate = !forceNoAnimation && 336 shouldAnimateTransition(desiredLocation, previousLocation) 337 val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation) 338 val host = getHost(desiredLocation) 339 mediaCarouselController.onDesiredLocationChanged(desiredLocation, host, animate, 340 animDuration, delay) 341 performTransitionToNewLocation(isNewView, animate) 342 } 343 } 344 performTransitionToNewLocationnull345 private fun performTransitionToNewLocation(isNewView: Boolean, animate: Boolean) { 346 if (previousLocation < 0 || isNewView) { 347 cancelAnimationAndApplyDesiredState() 348 return 349 } 350 val currentHost = getHost(desiredLocation) 351 val previousHost = getHost(previousLocation) 352 if (currentHost == null || previousHost == null) { 353 cancelAnimationAndApplyDesiredState() 354 return 355 } 356 updateTargetState() 357 if (isCurrentlyInGuidedTransformation()) { 358 applyTargetStateIfNotAnimating() 359 } else if (animate) { 360 animator.cancel() 361 if (currentAttachmentLocation != previousLocation || 362 !previousHost.hostView.isAttachedToWindow) { 363 // Let's animate to the new position, starting from the current position 364 // We also go in here in case the view was detached, since the bounds wouldn't 365 // be correct anymore 366 animationStartBounds.set(currentBounds) 367 } else { 368 // otherwise, let's take the freshest state, since the current one could 369 // be outdated 370 animationStartBounds.set(previousHost.currentBounds) 371 } 372 adjustAnimatorForTransition(desiredLocation, previousLocation) 373 if (!animationPending) { 374 rootView?.let { 375 // Let's delay the animation start until we finished laying out 376 animationPending = true 377 it.postOnAnimation(startAnimation) 378 } 379 } 380 } else { 381 cancelAnimationAndApplyDesiredState() 382 } 383 } 384 shouldAnimateTransitionnull385 private fun shouldAnimateTransition( 386 @MediaLocation currentLocation: Int, 387 @MediaLocation previousLocation: Int 388 ): Boolean { 389 if (isCurrentlyInGuidedTransformation()) { 390 return false 391 } 392 // This is an invalid transition, and can happen when using the camera gesture from the 393 // lock screen. Disallow. 394 if (previousLocation == LOCATION_LOCKSCREEN && 395 desiredLocation == LOCATION_QQS && 396 statusbarState == StatusBarState.SHADE) { 397 return false 398 } 399 400 if (currentLocation == LOCATION_QQS && 401 previousLocation == LOCATION_LOCKSCREEN && 402 (statusBarStateController.leaveOpenOnKeyguardHide() || 403 statusbarState == StatusBarState.SHADE_LOCKED)) { 404 // Usually listening to the isShown is enough to determine this, but there is some 405 // non-trivial reattaching logic happening that will make the view not-shown earlier 406 return true 407 } 408 return mediaFrame.isShownNotFaded || animator.isRunning || animationPending 409 } 410 adjustAnimatorForTransitionnull411 private fun adjustAnimatorForTransition(desiredLocation: Int, previousLocation: Int) { 412 val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation) 413 animator.apply { 414 duration = animDuration 415 startDelay = delay 416 } 417 } 418 getAnimationParamsnull419 private fun getAnimationParams(previousLocation: Int, desiredLocation: Int): Pair<Long, Long> { 420 var animDuration = 200L 421 var delay = 0L 422 if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) { 423 // Going to the full shade, let's adjust the animation duration 424 if (statusbarState == StatusBarState.SHADE && 425 keyguardStateController.isKeyguardFadingAway) { 426 delay = keyguardStateController.keyguardFadingAwayDelay 427 } 428 animDuration = StackStateAnimator.ANIMATION_DURATION_GO_TO_FULL_SHADE.toLong() 429 } else if (previousLocation == LOCATION_QQS && desiredLocation == LOCATION_LOCKSCREEN) { 430 animDuration = StackStateAnimator.ANIMATION_DURATION_APPEAR_DISAPPEAR.toLong() 431 } 432 return animDuration to delay 433 } 434 applyTargetStateIfNotAnimatingnull435 private fun applyTargetStateIfNotAnimating() { 436 if (!animator.isRunning) { 437 // Let's immediately apply the target state (which is interpolated) if there is 438 // no animation running. Otherwise the animation update will already update 439 // the location 440 applyState(targetBounds) 441 } 442 } 443 444 /** 445 * Updates the bounds that the view wants to be in at the end of the animation. 446 */ updateTargetStatenull447 private fun updateTargetState() { 448 if (isCurrentlyInGuidedTransformation()) { 449 val progress = getTransformationProgress() 450 var endHost = getHost(desiredLocation)!! 451 var starthost = getHost(previousLocation)!! 452 // If either of the hosts are invisible, let's keep them at the other host location to 453 // have a nicer disappear animation. Otherwise the currentBounds of the state might 454 // be undefined 455 if (!endHost.visible) { 456 endHost = starthost 457 } else if (!starthost.visible) { 458 starthost = endHost 459 } 460 val newBounds = endHost.currentBounds 461 val previousBounds = starthost.currentBounds 462 targetBounds = interpolateBounds(previousBounds, newBounds, progress) 463 } else { 464 val bounds = getHost(desiredLocation)?.currentBounds ?: return 465 targetBounds.set(bounds) 466 } 467 } 468 interpolateBoundsnull469 private fun interpolateBounds( 470 startBounds: Rect, 471 endBounds: Rect, 472 progress: Float, 473 result: Rect? = null 474 ): Rect { 475 val left = MathUtils.lerp(startBounds.left.toFloat(), 476 endBounds.left.toFloat(), progress).toInt() 477 val top = MathUtils.lerp(startBounds.top.toFloat(), 478 endBounds.top.toFloat(), progress).toInt() 479 val right = MathUtils.lerp(startBounds.right.toFloat(), 480 endBounds.right.toFloat(), progress).toInt() 481 val bottom = MathUtils.lerp(startBounds.bottom.toFloat(), 482 endBounds.bottom.toFloat(), progress).toInt() 483 val resultBounds = result ?: Rect() 484 resultBounds.set(left, top, right, bottom) 485 return resultBounds 486 } 487 488 /** 489 * @return true if this transformation is guided by an external progress like a finger 490 */ isCurrentlyInGuidedTransformationnull491 private fun isCurrentlyInGuidedTransformation(): Boolean { 492 return getTransformationProgress() >= 0 493 } 494 495 /** 496 * @return the current transformation progress if we're in a guided transformation and -1 497 * otherwise 498 */ getTransformationProgressnull499 private fun getTransformationProgress(): Float { 500 val progress = getQSTransformationProgress() 501 if (progress >= 0) { 502 return progress 503 } 504 return -1.0f 505 } 506 getQSTransformationProgressnull507 private fun getQSTransformationProgress(): Float { 508 val currentHost = getHost(desiredLocation) 509 val previousHost = getHost(previousLocation) 510 if (currentHost?.location == LOCATION_QS) { 511 if (previousHost?.location == LOCATION_QQS) { 512 if (previousHost.visible || statusbarState != StatusBarState.KEYGUARD) { 513 return qsExpansion 514 } 515 } 516 } 517 return -1.0f 518 } 519 getHostnull520 private fun getHost(@MediaLocation location: Int): MediaHost? { 521 if (location < 0) { 522 return null 523 } 524 return mediaHosts[location] 525 } 526 cancelAnimationAndApplyDesiredStatenull527 private fun cancelAnimationAndApplyDesiredState() { 528 animator.cancel() 529 getHost(desiredLocation)?.let { 530 applyState(it.currentBounds, immediately = true) 531 } 532 } 533 534 /** 535 * Apply the current state to the view, updating it's bounds and desired state 536 */ applyStatenull537 private fun applyState(bounds: Rect, immediately: Boolean = false) { 538 currentBounds.set(bounds) 539 val currentlyInGuidedTransformation = isCurrentlyInGuidedTransformation() 540 val startLocation = if (currentlyInGuidedTransformation) previousLocation else -1 541 val progress = if (currentlyInGuidedTransformation) getTransformationProgress() else 1.0f 542 val endLocation = desiredLocation 543 mediaCarouselController.setCurrentState(startLocation, endLocation, progress, immediately) 544 updateHostAttachment() 545 if (currentAttachmentLocation == IN_OVERLAY) { 546 mediaFrame.setLeftTopRightBottom( 547 currentBounds.left, 548 currentBounds.top, 549 currentBounds.right, 550 currentBounds.bottom) 551 } 552 } 553 updateHostAttachmentnull554 private fun updateHostAttachment() { 555 val inOverlay = isTransitionRunning() && rootOverlay != null 556 val newLocation = if (inOverlay) IN_OVERLAY else desiredLocation 557 if (currentAttachmentLocation != newLocation) { 558 currentAttachmentLocation = newLocation 559 560 // Remove the carousel from the old host 561 (mediaFrame.parent as ViewGroup?)?.removeView(mediaFrame) 562 563 // Add it to the new one 564 val targetHost = getHost(desiredLocation)!!.hostView 565 if (inOverlay) { 566 rootOverlay!!.add(mediaFrame) 567 } else { 568 // When adding back to the host, let's make sure to reset the bounds. 569 // Usually adding the view will trigger a layout that does this automatically, 570 // but we sometimes suppress this. 571 targetHost.addView(mediaFrame) 572 val left = targetHost.paddingLeft 573 val top = targetHost.paddingTop 574 mediaFrame.setLeftTopRightBottom( 575 left, 576 top, 577 left + currentBounds.width(), 578 top + currentBounds.height()) 579 } 580 } 581 } 582 isTransitionRunningnull583 private fun isTransitionRunning(): Boolean { 584 return isCurrentlyInGuidedTransformation() && getTransformationProgress() != 1.0f || 585 animator.isRunning || animationPending 586 } 587 588 @MediaLocation calculateLocationnull589 private fun calculateLocation(): Int { 590 if (blockLocationChanges) { 591 // Keep the current location until we're allowed to again 592 return desiredLocation 593 } 594 val onLockscreen = (!bypassController.bypassEnabled && 595 (statusbarState == StatusBarState.KEYGUARD || 596 statusbarState == StatusBarState.FULLSCREEN_USER_SWITCHER)) 597 val allowedOnLockscreen = notifLockscreenUserManager.shouldShowLockscreenNotifications() 598 val location = when { 599 qsExpansion > 0.0f && !onLockscreen -> LOCATION_QS 600 qsExpansion > 0.4f && onLockscreen -> LOCATION_QS 601 onLockscreen && allowedOnLockscreen -> LOCATION_LOCKSCREEN 602 else -> LOCATION_QQS 603 } 604 // When we're on lock screen and the player is not active, we should keep it in QS. 605 // Otherwise it will try to animate a transition that doesn't make sense. 606 if (location == LOCATION_LOCKSCREEN && getHost(location)?.visible != true && 607 !statusBarStateController.isDozing) { 608 return LOCATION_QS 609 } 610 if (location == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QS && 611 collapsingShadeFromQS) { 612 // When collapsing on the lockscreen, we want to remain in QS 613 return LOCATION_QS 614 } 615 if (location != LOCATION_LOCKSCREEN && desiredLocation == LOCATION_LOCKSCREEN && 616 !fullyAwake) { 617 // When unlocking from dozing / while waking up, the media shouldn't be transitioning 618 // in an animated way. Let's keep it in the lockscreen until we're fully awake and 619 // reattach it without an animation 620 return LOCATION_LOCKSCREEN 621 } 622 return location 623 } 624 625 companion object { 626 /** 627 * Attached in expanded quick settings 628 */ 629 const val LOCATION_QS = 0 630 631 /** 632 * Attached in the collapsed QS 633 */ 634 const val LOCATION_QQS = 1 635 636 /** 637 * Attached on the lock screen 638 */ 639 const val LOCATION_LOCKSCREEN = 2 640 641 /** 642 * Attached at the root of the hierarchy in an overlay 643 */ 644 const val IN_OVERLAY = -1000 645 } 646 } 647 648 @IntDef(prefix = ["LOCATION_"], value = [MediaHierarchyManager.LOCATION_QS, 649 MediaHierarchyManager.LOCATION_QQS, MediaHierarchyManager.LOCATION_LOCKSCREEN]) 650 @Retention(AnnotationRetention.SOURCE) 651 annotation class MediaLocation