| /* |
| * 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() |
| } |
| } |
| } |
| } |