PreviewAnimationClock.kt

/*
 * Copyright 2020 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 androidx.compose.ui.tooling.preview.animation

import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.InternalAnimationApi
import androidx.compose.animation.core.Transition
import androidx.compose.animation.tooling.ComposeAnimatedProperty
import androidx.compose.animation.tooling.ComposeAnimation
import androidx.compose.animation.tooling.ComposeAnimationType
import java.util.concurrent.TimeUnit

/**
 * Used to keep track and control animations in the context of Compose Previews. This class is
 * expected to be controlled by the Animation Preview in Android Studio, and most of its methods
 * will be called via reflection, either directly from Android Studio or through
 * `ComposeViewAdapter`.
 *
 * Users will be able to select specific frames of tracked animations when inspecting them in
 * Android Studio through [setClockTime], and read the values of their animated properties by
 * calling [getAnimatedProperties].
 *
 * @suppress
 */
@OptIn(InternalAnimationApi::class)
internal open class PreviewAnimationClock(private val setAnimationsTimeCallback: () -> Unit = {}) {

    private val TAG = "PreviewAnimationClock"

    private val DEBUG = false

    /**
     * Maps [ComposeAnimation]s representing [Transition]s to their corresponding [Transition]
     * objects. These objects are used when setting the clock time, where we call `seek`, and in
     * [getAnimatedProperties], where we get the animation values.
     */
    @VisibleForTesting
    internal val trackedTransitions = hashMapOf<ComposeAnimation, Transition<Any>>()

    /**
     * Maps [Transition]s to their corresponding cached [TransitionState], which we use to seek
     * the animations when updating the clock time.
     */
    @VisibleForTesting
    internal val transitionStates = hashMapOf<Transition<Any>, TransitionState>()
    private val transitionStatesLock = Any()

    fun trackTransition(transition: Transition<Any>) {
        synchronized(transitionStatesLock) {
            if (transitionStates.containsKey(transition)) {
                if (DEBUG) {
                    Log.d(TAG, "Transition $transition is already being tracked")
                }
                return@trackTransition
            }
            transitionStates[transition] =
                TransitionState(transition.currentState, transition.targetState)
        }

        if (DEBUG) {
            Log.d(TAG, "Transition $transition is now tracked")
        }

        val composeAnimation = transition.parse()
        trackedTransitions[composeAnimation] = transition
        notifySubscribe(composeAnimation)
    }

    @VisibleForTesting
    protected open fun notifySubscribe(animation: ComposeAnimation) {
        // This method is expected to be no-op. It is intercepted in Android Studio using bytecode
        // manipulation, in order for the tools to be aware that the animation is now tracked.
    }

    @VisibleForTesting
    protected open fun notifyUnsubscribe(animation: ComposeAnimation) {
        // This method is expected to be no-op. It is intercepted in Android Studio using bytecode
        // manipulation, in order for the tools to be aware that the animation is no longer
        // tracked.
    }

    /**
     * Updates the [TransitionState] corresponding to the given [ComposeAnimation] in the
     * [transitionStates] map, creating a [TransitionState] with the given `from` and `to` states.
     */
    fun updateFromAndToStates(composeAnimation: ComposeAnimation, fromState: Any, toState: Any) {
        if (composeAnimation.type != ComposeAnimationType.TRANSITION_ANIMATION) return

        trackedTransitions.entries.firstOrNull { it.key == composeAnimation }
            ?.let { transitionEntry ->
                val transition = transitionEntry.value
                synchronized(transitionStatesLock) {
                    transitionStates[transition] = TransitionState(fromState, toState)
                }
            }
    }

    /**
     * Returns the duration (ms) of the longest animation being tracked.
     */
    fun getMaxDuration(): Long {
        // TODO(b/160126628): support other animation types, e.g. AnimatedValue
        return trackedTransitions.map { entry ->
            val composeAnimation = entry.key
            if (composeAnimation.type == ComposeAnimationType.TRANSITION_ANIMATION)
                nanosToMillis(entry.value.totalDurationNanos)
            else -1
        }.maxOrNull() ?: -1
    }

    /**
     * Returns the longest duration (ms) per iteration among the animations being tracked. This
     * can be different from [getMaxDuration], for instance, when there is one or more repeatable
     * animations with multiple iterations.
     *
     * TODO(b/177895209): re-add support repeatable/infinite animations.
     */
    fun getMaxDurationPerIteration(): Long {
        // TODO(b/160126628): support other animation types, e.g. AnimatedValue
        return trackedTransitions.map { entry ->
            val composeAnimation = entry.key
            if (composeAnimation.type == ComposeAnimationType.TRANSITION_ANIMATION)
                nanosToMillis(entry.value.totalDurationNanos)
            else -1
        }.maxOrNull() ?: -1
    }

    /**
     *  Returns a list of the given [Transition]'s animated properties. The properties are
     *  wrapped into a [Pair] of property label and the corresponding value at the current time.
     */
    fun getAnimatedProperties(animation: ComposeAnimation): List<ComposeAnimatedProperty> {
        if (animation.type != ComposeAnimationType.TRANSITION_ANIMATION) return emptyList()
        trackedTransitions[animation]?.let { transition ->
            return transition.animations.mapNotNull {
                val value = it.value ?: return@mapNotNull null
                ComposeAnimatedProperty(it.label, value)
            }
        }
        return emptyList()
    }

    /**
     * Seeks each animation being tracked to the given [animationTimeMs]. Expected to be called
     * via reflection from Android Studio.
     */
    fun setClockTime(animationTimeMs: Long) {
        val timeNs = TimeUnit.MILLISECONDS.toNanos(animationTimeMs)
        trackedTransitions.forEach { entry ->
            val composeAnimation = entry.key
            if (composeAnimation.type == ComposeAnimationType.TRANSITION_ANIMATION) {
                entry.value.let {
                    val states = transitionStates[it] ?: return@let
                    it.seek(states.current, states.target, timeNs)
                }
            }
        }
        setAnimationsTimeCallback.invoke()
    }

    /**
     * Unsubscribes the currently tracked animations and clears all the caches.
     */
    fun dispose() {
        trackedTransitions.forEach { notifyUnsubscribe(it.key) }
        trackedTransitions.clear()
        transitionStates.clear()
    }

    @VisibleForTesting
    internal data class TransitionState(val current: Any, val target: Any)

    /**
     * Converts the given time in nanoseconds to milliseconds, rounding up when needed.
     */
    private fun nanosToMillis(timeNs: Long) = (timeNs + 999_999) / 1_000_000
}