blob: bb6eb78aac65cad91452eac9b216c6fa1b0d9482 [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.animation
import android.app.ActivityManager
import android.app.ActivityTaskManager
import android.app.PendingIntent
import android.app.TaskInfo
import android.graphics.Matrix
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.os.Looper
import android.os.RemoteException
import android.util.Log
import android.view.IRemoteAnimationFinishedCallback
import android.view.IRemoteAnimationRunner
import android.view.RemoteAnimationAdapter
import android.view.RemoteAnimationTarget
import android.view.SyncRtSurfaceTransactionApplier
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.Interpolator
import android.view.animation.PathInterpolator
import com.android.internal.annotations.VisibleForTesting
import com.android.internal.policy.ScreenDecorationsUtils
import kotlin.math.roundToInt
private const val TAG = "ActivityLaunchAnimator"
/**
* A class that allows activities to be started in a seamless way from a view that is transforming
* nicely into the starting window.
*/
class ActivityLaunchAnimator(
/** The animator used when animating a View into an app. */
private val launchAnimator: LaunchAnimator = LaunchAnimator(TIMINGS, INTERPOLATORS),
/** The animator used when animating a Dialog into an app. */
// TODO(b/218989950): Remove this animator and instead set the duration of the dim fade out to
// TIMINGS.contentBeforeFadeOutDuration.
private val dialogToAppAnimator: LaunchAnimator = LaunchAnimator(DIALOG_TIMINGS, INTERPOLATORS)
) {
companion object {
/** The timings when animating a View into an app. */
@JvmField
val TIMINGS = LaunchAnimator.Timings(
totalDuration = 500L,
contentBeforeFadeOutDelay = 0L,
contentBeforeFadeOutDuration = 150L,
contentAfterFadeInDelay = 150L,
contentAfterFadeInDuration = 183L
)
/**
* The timings when animating a Dialog into an app. We need to wait at least 200ms before
* showing the app (which is under the dialog window) so that the dialog window dim is fully
* faded out, to avoid flicker.
*/
val DIALOG_TIMINGS = TIMINGS.copy(
contentBeforeFadeOutDuration = 200L,
contentAfterFadeInDelay = 200L
)
/** The interpolators when animating a View or a dialog into an app. */
val INTERPOLATORS = LaunchAnimator.Interpolators(
positionInterpolator = Interpolators.EMPHASIZED,
positionXInterpolator = createPositionXInterpolator(),
contentBeforeFadeOutInterpolator = Interpolators.LINEAR_OUT_SLOW_IN,
contentAfterFadeInInterpolator = PathInterpolator(0f, 0f, 0.6f, 1f)
)
/** Durations & interpolators for the navigation bar fading in & out. */
private const val ANIMATION_DURATION_NAV_FADE_IN = 266L
private const val ANIMATION_DURATION_NAV_FADE_OUT = 133L
private val ANIMATION_DELAY_NAV_FADE_IN =
TIMINGS.totalDuration - ANIMATION_DURATION_NAV_FADE_IN
private val NAV_FADE_IN_INTERPOLATOR = Interpolators.STANDARD_DECELERATE
private val NAV_FADE_OUT_INTERPOLATOR = PathInterpolator(0.2f, 0f, 1f, 1f)
/** The time we wait before timing out the remote animation after starting the intent. */
private const val LAUNCH_TIMEOUT = 1000L
private fun createPositionXInterpolator(): Interpolator {
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.1217f, 0.0462f, 0.15f, 0.4686f, 0.1667f, 0.66f)
cubicTo(0.1834f, 0.8878f, 0.1667f, 1f, 1f, 1f)
}
return PathInterpolator(path)
}
}
/**
* The callback of this animator. This should be set before any call to
* [start(Pending)IntentWithAnimation].
*/
var callback: Callback? = null
/** The set of [Listener] that should be notified of any animation started by this animator. */
private val listeners = LinkedHashSet<Listener>()
/**
* Start an intent and animate the opening window. The intent will be started by running
* [intentStarter], which should use the provided [RemoteAnimationAdapter] and return the launch
* result. [controller] is responsible from animating the view from which the intent was started
* in [Controller.onLaunchAnimationProgress]. No animation will start if there is no window
* opening.
*
* If [controller] is null or [animate] is false, then the intent will be started and no
* animation will run.
*
* If possible, you should pass the [packageName] of the intent that will be started so that
* trampoline activity launches will also be animated.
*
* If the device is currently locked, the user will have to unlock it before the intent is
* started unless [showOverLockscreen] is true. In that case, the activity will be started
* directly over the lockscreen.
*
* This method will throw any exception thrown by [intentStarter].
*/
@JvmOverloads
fun startIntentWithAnimation(
controller: Controller?,
animate: Boolean = true,
packageName: String? = null,
showOverLockscreen: Boolean = false,
intentStarter: (RemoteAnimationAdapter?) -> Int
) {
if (controller == null || !animate) {
Log.i(TAG, "Starting intent with no animation")
intentStarter(null)
controller?.callOnIntentStartedOnMainThread(willAnimate = false)
return
}
val callback = this.callback ?: throw IllegalStateException(
"ActivityLaunchAnimator.callback must be set before using this animator")
val runner = Runner(controller)
val hideKeyguardWithAnimation = callback.isOnKeyguard() && !showOverLockscreen
// Pass the RemoteAnimationAdapter to the intent starter only if we are not hiding the
// keyguard with the animation
val animationAdapter = if (!hideKeyguardWithAnimation) {
RemoteAnimationAdapter(
runner,
TIMINGS.totalDuration,
TIMINGS.totalDuration - 150 /* statusBarTransitionDelay */
)
} else {
null
}
// Register the remote animation for the given package to also animate trampoline
// activity launches.
if (packageName != null && animationAdapter != null) {
try {
ActivityTaskManager.getService().registerRemoteAnimationForNextActivityStart(
packageName, animationAdapter, null /* launchCookie */)
} catch (e: RemoteException) {
Log.w(TAG, "Unable to register the remote animation", e)
}
}
val launchResult = intentStarter(animationAdapter)
// Only animate if the app is not already on top and will be opened, unless we are on the
// keyguard.
val willAnimate =
launchResult == ActivityManager.START_TASK_TO_FRONT ||
launchResult == ActivityManager.START_SUCCESS ||
(launchResult == ActivityManager.START_DELIVERED_TO_TOP &&
hideKeyguardWithAnimation)
Log.i(TAG, "launchResult=$launchResult willAnimate=$willAnimate " +
"hideKeyguardWithAnimation=$hideKeyguardWithAnimation")
controller.callOnIntentStartedOnMainThread(willAnimate)
// If we expect an animation, post a timeout to cancel it in case the remote animation is
// never started.
if (willAnimate) {
runner.postTimeout()
// Hide the keyguard using the launch animation instead of the default unlock animation.
if (hideKeyguardWithAnimation) {
callback.hideKeyguardWithAnimation(runner)
}
}
}
private fun Controller.callOnIntentStartedOnMainThread(willAnimate: Boolean) {
if (Looper.myLooper() != Looper.getMainLooper()) {
this.launchContainer.context.mainExecutor.execute {
this.onIntentStarted(willAnimate)
}
} else {
this.onIntentStarted(willAnimate)
}
}
/**
* Same as [startIntentWithAnimation] but allows [intentStarter] to throw a
* [PendingIntent.CanceledException] which must then be handled by the caller. This is useful
* for Java caller starting a [PendingIntent].
*
* If possible, you should pass the [packageName] of the intent that will be started so that
* trampoline activity launches will also be animated.
*/
@Throws(PendingIntent.CanceledException::class)
@JvmOverloads
fun startPendingIntentWithAnimation(
controller: Controller?,
animate: Boolean = true,
packageName: String? = null,
intentStarter: PendingIntentStarter
) {
startIntentWithAnimation(controller, animate, packageName) {
intentStarter.startPendingIntent(it)
}
}
/** Add a [Listener] that can listen to launch animations. */
fun addListener(listener: Listener) {
listeners.add(listener)
}
/** Remove a [Listener]. */
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
/** Create a new animation [Runner] controlled by [controller]. */
@VisibleForTesting
fun createRunner(controller: Controller): Runner = Runner(controller)
interface PendingIntentStarter {
/**
* Start a pending intent using the provided [animationAdapter] and return the launch
* result.
*/
@Throws(PendingIntent.CanceledException::class)
fun startPendingIntent(animationAdapter: RemoteAnimationAdapter?): Int
}
interface Callback {
/** Whether we are currently on the keyguard or not. */
fun isOnKeyguard(): Boolean
/** Hide the keyguard and animate using [runner]. */
fun hideKeyguardWithAnimation(runner: IRemoteAnimationRunner)
/* Get the background color of [task]. */
fun getBackgroundColor(task: TaskInfo): Int
}
interface Listener {
/** Called when an activity launch animation started. */
@JvmDefault
fun onLaunchAnimationStart() {}
/**
* Called when an activity launch animation is finished. This will be called if and only if
* [onLaunchAnimationStart] was called earlier.
*/
@JvmDefault
fun onLaunchAnimationEnd() {}
/** Called when an activity launch animation made progress. */
@JvmDefault
fun onLaunchAnimationProgress(linearProgress: Float) {}
}
/**
* A controller that takes care of applying the animation to an expanding view.
*
* Note that all callbacks (onXXX methods) are all called on the main thread.
*/
interface Controller : LaunchAnimator.Controller {
companion object {
/**
* Return a [Controller] that will animate and expand [view] into the opening window.
*
* Important: The view must be attached to a [ViewGroup] when calling this function and
* during the animation. For safety, this method will return null when it is not.
*
* Note: The background of [view] should be a (rounded) rectangle so that it can be
* properly animated.
*/
@JvmStatic
fun fromView(view: View, cujType: Int? = null): Controller? {
if (view.parent !is ViewGroup) {
// TODO(b/192194319): Throw instead of just logging.
Log.wtf(
TAG,
"Skipping animation as view $view is not attached to a ViewGroup",
Exception()
)
return null
}
return GhostedViewLaunchAnimatorController(view, cujType)
}
}
/**
* Whether this controller is controlling a dialog launch. This will be used to adapt the
* timings, making sure we don't show the app until the dialog dim had the time to fade out.
*/
// TODO(b/218989950): Remove this.
val isDialogLaunch: Boolean
get() = false
/**
* The intent was started. If [willAnimate] is false, nothing else will happen and the
* animation will not be started.
*/
fun onIntentStarted(willAnimate: Boolean) {}
/**
* The animation was cancelled. Note that [onLaunchAnimationEnd] will still be called after
* this if the animation was already started, i.e. if [onLaunchAnimationStart] was called
* before the cancellation.
*/
fun onLaunchAnimationCancelled() {}
}
@VisibleForTesting
inner class Runner(private val controller: Controller) : IRemoteAnimationRunner.Stub() {
private val launchContainer = controller.launchContainer
private val context = launchContainer.context
private val transactionApplierView =
controller.openingWindowSyncView ?: controller.launchContainer
private val transactionApplier = SyncRtSurfaceTransactionApplier(transactionApplierView)
private val matrix = Matrix()
private val invertMatrix = Matrix()
private var windowCrop = Rect()
private var windowCropF = RectF()
private var timedOut = false
private var cancelled = false
private var animation: LaunchAnimator.Animation? = null
// A timeout to cancel the remote animation if it is not started within X milliseconds after
// the intent was started.
//
// Note that this is important to keep this a Runnable (and not a Kotlin lambda), otherwise
// it will be automatically converted when posted and we wouldn't be able to remove it after
// posting it.
private var onTimeout = Runnable { onAnimationTimedOut() }
internal fun postTimeout() {
launchContainer.postDelayed(onTimeout, LAUNCH_TIMEOUT)
}
private fun removeTimeout() {
launchContainer.removeCallbacks(onTimeout)
}
override fun onAnimationStart(
@WindowManager.TransitionOldType transit: Int,
apps: Array<out RemoteAnimationTarget>?,
wallpapers: Array<out RemoteAnimationTarget>?,
nonApps: Array<out RemoteAnimationTarget>?,
iCallback: IRemoteAnimationFinishedCallback?
) {
removeTimeout()
// The animation was started too late and we already notified the controller that it
// timed out.
if (timedOut) {
iCallback?.invoke()
return
}
// This should not happen, but let's make sure we don't start the animation if it was
// cancelled before and we already notified the controller.
if (cancelled) {
return
}
context.mainExecutor.execute {
startAnimation(apps, nonApps, iCallback)
}
}
private fun startAnimation(
apps: Array<out RemoteAnimationTarget>?,
nonApps: Array<out RemoteAnimationTarget>?,
iCallback: IRemoteAnimationFinishedCallback?
) {
if (LaunchAnimator.DEBUG) {
Log.d(TAG, "Remote animation started")
}
val window = apps?.firstOrNull {
it.mode == RemoteAnimationTarget.MODE_OPENING
}
if (window == null) {
Log.i(TAG, "Aborting the animation as no window is opening")
removeTimeout()
iCallback?.invoke()
controller.onLaunchAnimationCancelled()
return
}
val navigationBar = nonApps?.firstOrNull {
it.windowType == WindowManager.LayoutParams.TYPE_NAVIGATION_BAR
}
val windowBounds = window.screenSpaceBounds
val endState = LaunchAnimator.State(
top = windowBounds.top,
bottom = windowBounds.bottom,
left = windowBounds.left,
right = windowBounds.right
)
val callback = this@ActivityLaunchAnimator.callback!!
val windowBackgroundColor = window.taskInfo?.let { callback.getBackgroundColor(it) }
?: window.backgroundColor
// Make sure we use the modified timings when animating a dialog into an app.
val launchAnimator = if (controller.isDialogLaunch) {
dialogToAppAnimator
} else {
launchAnimator
}
// TODO(b/184121838): We should somehow get the top and bottom radius of the window
// instead of recomputing isExpandingFullyAbove here.
val isExpandingFullyAbove =
launchAnimator.isExpandingFullyAbove(controller.launchContainer, endState)
val endRadius = if (isExpandingFullyAbove) {
// Most of the time, expanding fully above the root view means expanding in full
// screen.
ScreenDecorationsUtils.getWindowCornerRadius(context)
} else {
// This usually means we are in split screen mode, so 2 out of 4 corners will have
// a radius of 0.
0f
}
endState.topCornerRadius = endRadius
endState.bottomCornerRadius = endRadius
// We animate the opening window and delegate the view expansion to [this.controller].
val delegate = this.controller
val controller = object : LaunchAnimator.Controller by delegate {
override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) {
listeners.forEach { it.onLaunchAnimationStart() }
delegate.onLaunchAnimationStart(isExpandingFullyAbove)
}
override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) {
listeners.forEach { it.onLaunchAnimationEnd() }
iCallback?.invoke()
delegate.onLaunchAnimationEnd(isExpandingFullyAbove)
}
override fun onLaunchAnimationProgress(
state: LaunchAnimator.State,
progress: Float,
linearProgress: Float
) {
// Apply the state to the window only if it is visible, i.e. when the expanding
// view is *not* visible.
if (!state.visible) {
applyStateToWindow(window, state)
}
navigationBar?.let { applyStateToNavigationBar(it, state, linearProgress) }
listeners.forEach { it.onLaunchAnimationProgress(linearProgress) }
delegate.onLaunchAnimationProgress(state, progress, linearProgress)
}
}
animation = launchAnimator.startAnimation(
controller, endState, windowBackgroundColor, drawHole = true)
}
private fun applyStateToWindow(window: RemoteAnimationTarget, state: LaunchAnimator.State) {
if (transactionApplierView.viewRootImpl == null) {
// If the view root we synchronize with was detached, don't apply any transaction
// (as [SyncRtSurfaceTransactionApplier.scheduleApply] would otherwise throw).
return
}
val screenBounds = window.screenSpaceBounds
val centerX = (screenBounds.left + screenBounds.right) / 2f
val centerY = (screenBounds.top + screenBounds.bottom) / 2f
val width = screenBounds.right - screenBounds.left
val height = screenBounds.bottom - screenBounds.top
// Scale the window. We use the max of (widthRatio, heightRatio) so that there is no
// blank space on any side.
val widthRatio = state.width.toFloat() / width
val heightRatio = state.height.toFloat() / height
val scale = maxOf(widthRatio, heightRatio)
matrix.reset()
matrix.setScale(scale, scale, centerX, centerY)
// Align it to the top and center it in the x-axis.
val heightChange = height * scale - height
val translationX = state.centerX - centerX
val translationY = state.top - screenBounds.top + heightChange / 2f
matrix.postTranslate(translationX, translationY)
// Crop it. The matrix will also be applied to the crop, so we apply the inverse
// operation. Given that we only scale (by factor > 0) then translate, we can assume
// that the matrix is invertible.
val cropX = state.left.toFloat() - screenBounds.left
val cropY = state.top.toFloat() - screenBounds.top
windowCropF.set(cropX, cropY, cropX + state.width, cropY + state.height)
matrix.invert(invertMatrix)
invertMatrix.mapRect(windowCropF)
windowCrop.set(
windowCropF.left.roundToInt(),
windowCropF.top.roundToInt(),
windowCropF.right.roundToInt(),
windowCropF.bottom.roundToInt()
)
// The scale will also be applied to the corner radius, so we divide by the scale to
// keep the original radius. We use the max of (topCornerRadius, bottomCornerRadius) to
// make sure that the window does not draw itself behind the expanding view. This is
// especially important for lock screen animations, where the window is not clipped by
// the shade.
val cornerRadius = maxOf(state.topCornerRadius, state.bottomCornerRadius) / scale
val params = SyncRtSurfaceTransactionApplier.SurfaceParams.Builder(window.leash)
.withAlpha(1f)
.withMatrix(matrix)
.withWindowCrop(windowCrop)
.withCornerRadius(cornerRadius)
.withVisibility(true)
.build()
transactionApplier.scheduleApply(params)
}
private fun applyStateToNavigationBar(
navigationBar: RemoteAnimationTarget,
state: LaunchAnimator.State,
linearProgress: Float
) {
if (transactionApplierView.viewRootImpl == null) {
// If the view root we synchronize with was detached, don't apply any transaction
// (as [SyncRtSurfaceTransactionApplier.scheduleApply] would otherwise throw).
return
}
val fadeInProgress = LaunchAnimator.getProgress(TIMINGS, linearProgress,
ANIMATION_DELAY_NAV_FADE_IN, ANIMATION_DURATION_NAV_FADE_OUT)
val params = SyncRtSurfaceTransactionApplier.SurfaceParams.Builder(navigationBar.leash)
if (fadeInProgress > 0) {
matrix.reset()
matrix.setTranslate(
0f, (state.top - navigationBar.sourceContainerBounds.top).toFloat())
windowCrop.set(state.left, 0, state.right, state.height)
params
.withAlpha(NAV_FADE_IN_INTERPOLATOR.getInterpolation(fadeInProgress))
.withMatrix(matrix)
.withWindowCrop(windowCrop)
.withVisibility(true)
} else {
val fadeOutProgress = LaunchAnimator.getProgress(TIMINGS, linearProgress, 0,
ANIMATION_DURATION_NAV_FADE_OUT)
params.withAlpha(1f - NAV_FADE_OUT_INTERPOLATOR.getInterpolation(fadeOutProgress))
}
transactionApplier.scheduleApply(params.build())
}
private fun onAnimationTimedOut() {
if (cancelled) {
return
}
Log.i(TAG, "Remote animation timed out")
timedOut = true
controller.onLaunchAnimationCancelled()
}
override fun onAnimationCancelled(isKeyguardOccluded: Boolean) {
if (timedOut) {
return
}
Log.i(TAG, "Remote animation was cancelled")
cancelled = true
removeTimeout()
context.mainExecutor.execute {
animation?.cancel()
controller.onLaunchAnimationCancelled()
}
}
private fun IRemoteAnimationFinishedCallback.invoke() {
try {
onAnimationFinished()
} catch (e: RemoteException) {
e.printStackTrace()
}
}
}
}