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