/*
* Copyright (C) 2023 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 android.animation;
import android.animation.AnimationHandler.AnimationFrameCallback;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.os.Looper;
import android.os.SystemClock;
import android.testing.TestableLooper;
import android.testing.TestableLooper.RunnableWithException;
import android.util.AndroidRuntimeException;
import android.util.Singleton;
import android.view.Choreographer;
import android.view.animation.AnimationUtils;
import androidx.test.platform.app.InstrumentationRegistry;
import com.android.internal.util.Preconditions;
import org.junit.AssumptionViolatedException;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* JUnit {@link TestRule} that can be used to run {@link Animator}s without actually waiting for the
* duration of the animation. This also helps the test to be written in a deterministic manner.
*
* Create an instance of {@code AnimatorTestRule} and specify it as a {@link org.junit.Rule}
* of the test class. Use {@link #advanceTimeBy(long)} to advance animators that have been started.
* Note that {@link #advanceTimeBy(long)} should be called from the same thread you have used to
* start the animator.
*
*
* {@literal @}SmallTest
* {@literal @}RunWith(AndroidJUnit4.class)
* public class SampleAnimatorTest {
*
* {@literal @}Rule
* public final AnimatorTestRule mAnimatorTestRule = new AnimatorTestRule();
*
* {@literal @}UiThreadTest
* {@literal @}Test
* public void sample() {
* final ValueAnimator animator = ValueAnimator.ofInt(0, 1000);
* animator.setDuration(1000L);
* assertThat(animator.getAnimatedValue(), is(0));
* animator.start();
* mAnimatorTestRule.advanceTimeBy(500L);
* assertThat(animator.getAnimatedValue(), is(500));
* }
* }
*
*/
public final class AnimatorTestRule implements TestRule {
private final Object mLock = new Object();
private final Singleton mTestHandler = new Singleton<>() {
@Override
protected TestHandler create() {
return new TestHandler();
}
};
private final Object mTest;
private final long mStartTime;
private long mTotalTimeDelta = 0;
private volatile boolean mCanLockAnimationClock;
private Looper mLooperWithLockedAnimationClock;
/**
* Construct an AnimatorTestRule with access to the test instance and a custom start time.
* @see #AnimatorTestRule(Object)
*/
public AnimatorTestRule(Object test, long startTime) {
mTest = test;
mStartTime = startTime;
}
/**
* Construct an AnimatorTestRule for the given test instance with a start time of
* {@link SystemClock#uptimeMillis()}. Initializing the start time with this clock reduces the
* discrepancies with various internals of classes like ValueAnimator which can sometimes read
* that clock via {@link android.view.animation.AnimationUtils#currentAnimationTimeMillis()}.
*
* @param test the test instance used to access the {@link TestableLooper} used by the class.
*/
public AnimatorTestRule(Object test) {
this(test, SystemClock.uptimeMillis());
}
@NonNull
@Override
public Statement apply(@NonNull final Statement base, @NonNull Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
final TestHandler testHandler = mTestHandler.get();
final AnimationHandler objAtStart = AnimationHandler.setTestHandler(testHandler);
final RunnableWithException lockClock =
wrapWithRunBlocking(new LockAnimationClockRunnable());
final RunnableWithException unlockClock =
wrapWithRunBlocking(new UnlockAnimationClockRunnable());
try {
lockClock.run();
base.evaluate();
} finally {
unlockClock.run();
AnimationHandler objAtEnd = AnimationHandler.setTestHandler(objAtStart);
if (testHandler != objAtEnd) {
// pass or fail, inner logic not restoring the handler needs to be reported.
// noinspection ThrowFromFinallyBlock
throw new IllegalStateException("Test handler was altered: expected="
+ testHandler + " actual=" + objAtEnd);
}
}
}
};
}
private RunnableWithException wrapWithRunBlocking(RunnableWithException runnable) {
RunnableWithException wrapped = TestableLooper.wrapWithRunBlocking(mTest, runnable);
if (wrapped != null) {
return wrapped;
}
return () -> runOnMainThrowing(runnable);
}
private static void runOnMainThrowing(RunnableWithException runnable) throws Exception {
if (Looper.myLooper() == Looper.getMainLooper()) {
runnable.run();
} else {
final Throwable[] throwableBox = new Throwable[1];
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
try {
runnable.run();
} catch (Throwable t) {
throwableBox[0] = t;
}
});
if (throwableBox[0] == null) {
return;
} else if (throwableBox[0] instanceof RuntimeException ex) {
throw ex;
} else if (throwableBox[0] instanceof Error err) {
throw err;
} else {
throw new RuntimeException(throwableBox[0]);
}
}
}
private class LockAnimationClockRunnable implements RunnableWithException {
@Override
public void run() {
mLooperWithLockedAnimationClock = Looper.myLooper();
mCanLockAnimationClock = true;
lockAnimationClockToCurrentTime();
}
}
private class UnlockAnimationClockRunnable implements RunnableWithException {
@Override
public void run() {
mCanLockAnimationClock = false;
mLooperWithLockedAnimationClock = null;
AnimationUtils.unlockAnimationClock();
}
}
private void lockAnimationClockToCurrentTime() {
if (!mCanLockAnimationClock) {
throw new AssertionError("Unable to lock the animation clock; "
+ "has the test started? already finished?");
}
if (mLooperWithLockedAnimationClock != Looper.myLooper()) {
throw new AssertionError("Animation clock being locked on " + Looper.myLooper()
+ " but should only be locked on " + mLooperWithLockedAnimationClock);
}
long desiredTime = getCurrentTime();
AnimationUtils.lockAnimationClock(desiredTime);
if (!mCanLockAnimationClock) {
AnimationUtils.unlockAnimationClock();
throw new AssertionError("Threading error when locking the animation clock");
}
long outputTime = AnimationUtils.currentAnimationTimeMillis();
if (outputTime != desiredTime) {
// Skip the test (rather than fail it) if there's a clock issue
throw new AssumptionViolatedException("currentAnimationTimeMillis() is " + outputTime
+ " after locking to " + desiredTime);
}
}
/**
* If any new {@link Animator}s have been registered since the last time the frame time was
* advanced, initialize them with the current frame time. Failing to do this will result in the
* animations beginning on the *next* advancement instead, so this is done automatically for
* test authors inside of {@link #advanceTimeBy}. However this is exposed in case authors want
* to validate operations performed by onStart listeners.
*
* NOTE: This is only required of the platform ValueAnimator because its start() method calls
* {@link AnimationHandler#addAnimationFrameCallback} BEFORE it calls startAnimation(), so this
* rule can't synchronously trigger the callback at that time.
*/
public void initNewAnimators() {
requireLooper("AnimationTestRule#initNewAnimators()");
long currentTime = getCurrentTime();
final TestHandler testHandler = mTestHandler.get();
List newCallbacks = new ArrayList<>(testHandler.mNewCallbacks);
testHandler.mNewCallbacks.clear();
for (AnimationFrameCallback newCallback : newCallbacks) {
newCallback.doAnimationFrame(currentTime);
}
}
/**
* Advances the animation clock by the given amount of delta in milliseconds. This call will
* produce an animation frame to all the ongoing animations. This method needs to be
* called on the same thread as {@link Animator#start()}.
*
* @param timeDelta the amount of milliseconds to advance
*/
public void advanceTimeBy(long timeDelta) {
advanceTimeBy(timeDelta, null);
}
/**
* Advances the animation clock by the given amount of delta in milliseconds. This call will
* produce an animation frame to all the ongoing animations. This method needs to be
* called on the same thread as {@link Animator#start()}.
*
* This method is not for test authors, but for rule authors to ensure that multiple animators
* can be advanced in sync.
*
* @param timeDelta the amount of milliseconds to advance
* @param preFrameAction a consumer to be passed the timeDelta following the time advancement
* but prior to the frame production.
*/
public void advanceTimeBy(long timeDelta, @Nullable Consumer preFrameAction) {
Preconditions.checkArgumentNonnegative(timeDelta, "timeDelta must not be negative");
requireLooper("AnimationTestRule#advanceTimeBy(long)");
final TestHandler testHandler = mTestHandler.get();
if (timeDelta == 0) {
// If time is not being advanced, all animators will get a tick; don't double tick these
testHandler.mNewCallbacks.clear();
} else {
// before advancing time, start new animators with the current time
initNewAnimators();
}
synchronized (mLock) {
// advance time
mTotalTimeDelta += timeDelta;
}
lockAnimationClockToCurrentTime();
if (preFrameAction != null) {
preFrameAction.accept(timeDelta);
// After letting other code run, clear any new callbacks to avoid double-ticking them
testHandler.mNewCallbacks.clear();
}
// produce a frame
testHandler.doFrame();
}
/**
* Returns the current time in milliseconds tracked by AnimationHandler. Note that this is a
* different time than the time tracked by {@link SystemClock} This method needs to be called on
* the same thread as {@link Animator#start()}.
*/
public long getCurrentTime() {
requireLooper("AnimationTestRule#getCurrentTime()");
synchronized (mLock) {
return mStartTime + mTotalTimeDelta;
}
}
private static void requireLooper(String method) {
if (Looper.myLooper() == null) {
throw new AndroidRuntimeException(method + " may only be called on Looper threads");
}
}
private class TestHandler extends AnimationHandler {
public final TestProvider mTestProvider = new TestProvider();
private final List mNewCallbacks = new ArrayList<>();
TestHandler() {
setProvider(mTestProvider);
}
public void doFrame() {
mTestProvider.animateFrame();
mTestProvider.commitFrame();
}
@Override
public void addAnimationFrameCallback(AnimationFrameCallback callback, long delay) {
// NOTE: using the delay is infeasible because the AnimationHandler uses
// SystemClock.uptimeMillis(); -- If we fix this to use an overridable method, then we
// could fix this for tests.
super.addAnimationFrameCallback(callback, 0);
if (delay <= 0) {
mNewCallbacks.add(callback);
}
}
@Override
public void removeCallback(AnimationFrameCallback callback) {
super.removeCallback(callback);
mNewCallbacks.remove(callback);
}
}
private class TestProvider implements AnimationHandler.AnimationFrameCallbackProvider {
private long mFrameDelay = 10;
private Choreographer.FrameCallback mFrameCallback = null;
private final List mCommitCallbacks = new ArrayList<>();
public void animateFrame() {
Choreographer.FrameCallback frameCallback = mFrameCallback;
mFrameCallback = null;
if (frameCallback != null) {
frameCallback.doFrame(getFrameTime());
}
}
public void commitFrame() {
List commitCallbacks = new ArrayList<>(mCommitCallbacks);
mCommitCallbacks.clear();
for (Runnable commitCallback : commitCallbacks) {
commitCallback.run();
}
}
@Override
public void postFrameCallback(Choreographer.FrameCallback callback) {
assert mFrameCallback == null;
mFrameCallback = callback;
}
@Override
public void postCommitCallback(Runnable runnable) {
mCommitCallbacks.add(runnable);
}
@Override
public void setFrameDelay(long delay) {
mFrameDelay = delay;
}
@Override
public long getFrameDelay() {
return mFrameDelay;
}
@Override
public long getFrameTime() {
return getCurrentTime();
}
}
}