summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Jeff DeCew <jeffdq@google.com> 2023-08-03 18:30:42 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2023-08-03 18:30:42 +0000
commitbb3e034bd0c7f28bece2b10e48851aab152a2878 (patch)
tree9042cd200e27be908d3dc294d0be029ee9630ab9
parentff40cef9914301eac986ec451d26c870fffc0f4d (diff)
parent705838998102826ba28144463f654e5006963d05 (diff)
Merge "Add AnimatorTestRule for use with platform animators" into udc-qpr-dev
-rw-r--r--core/java/android/animation/AnimationHandler.java21
-rw-r--r--packages/SystemUI/tests/src/android/animation/AnimatorTestRuleIsolationTest.kt90
-rw-r--r--packages/SystemUI/tests/src/android/animation/AnimatorTestRulePrecisionTest.kt193
-rw-r--r--packages/SystemUI/tests/src/androidx/core/animation/AnimatorTestRuleIsolationTest.kt (renamed from packages/SystemUI/tests/src/androidx/core/animation/AnimatorTestRuleTest.kt)15
-rw-r--r--packages/SystemUI/tests/utils/src/android/animation/AnimatorIsolationWorkaroundRule.kt111
-rw-r--r--packages/SystemUI/tests/utils/src/android/animation/AnimatorTestRule.java236
-rw-r--r--packages/SystemUI/tests/utils/src/android/animation/PlatformAnimatorIsolationRule.kt76
-rw-r--r--packages/SystemUI/tests/utils/src/androidx/core/animation/AndroidXAnimatorIsolationRule.kt39
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/util/test/TestExceptionDeferrer.kt56
9 files changed, 821 insertions, 16 deletions
diff --git a/core/java/android/animation/AnimationHandler.java b/core/java/android/animation/AnimationHandler.java
index df8a50a1fa5c..4fc90ae9d22c 100644
--- a/core/java/android/animation/AnimationHandler.java
+++ b/core/java/android/animation/AnimationHandler.java
@@ -16,6 +16,7 @@
package android.animation;
+import android.annotation.Nullable;
import android.os.SystemClock;
import android.os.SystemProperties;
import android.util.ArrayMap;
@@ -91,9 +92,13 @@ public class AnimationHandler {
};
public final static ThreadLocal<AnimationHandler> sAnimatorHandler = new ThreadLocal<>();
+ private static AnimationHandler sTestHandler = null;
private boolean mListDirty = false;
public static AnimationHandler getInstance() {
+ if (sTestHandler != null) {
+ return sTestHandler;
+ }
if (sAnimatorHandler.get() == null) {
sAnimatorHandler.set(new AnimationHandler());
}
@@ -101,6 +106,17 @@ public class AnimationHandler {
}
/**
+ * Sets an instance that will be returned by {@link #getInstance()} on every thread.
+ * @return the previously active test handler, if any.
+ * @hide
+ */
+ public static @Nullable AnimationHandler setTestHandler(@Nullable AnimationHandler handler) {
+ AnimationHandler oldHandler = sTestHandler;
+ sTestHandler = handler;
+ return oldHandler;
+ }
+
+ /**
* System property that controls the behavior of pausing infinite animators when an app
* is moved to the background.
*
@@ -369,7 +385,10 @@ public class AnimationHandler {
* Return the number of callbacks that have registered for frame callbacks.
*/
public static int getAnimationCount() {
- AnimationHandler handler = sAnimatorHandler.get();
+ AnimationHandler handler = sTestHandler;
+ if (handler == null) {
+ handler = sAnimatorHandler.get();
+ }
if (handler == null) {
return 0;
}
diff --git a/packages/SystemUI/tests/src/android/animation/AnimatorTestRuleIsolationTest.kt b/packages/SystemUI/tests/src/android/animation/AnimatorTestRuleIsolationTest.kt
new file mode 100644
index 000000000000..7e105cf19e2b
--- /dev/null
+++ b/packages/SystemUI/tests/src/android/animation/AnimatorTestRuleIsolationTest.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.testing.AndroidTestingRunner
+import android.testing.TestableLooper.RunWithLooper
+import androidx.core.animation.doOnEnd
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This test class validates that two tests' animators are isolated from each other when using the
+ * same animator test rule. This is a test to prevent future instances of b/275602127.
+ */
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+@RunWithLooper(setAsMainLooper = true)
+class AnimatorTestRuleIsolationTest : SysuiTestCase() {
+
+ @get:Rule val animatorTestRule = AnimatorTestRule()
+
+ @Test
+ fun testA() {
+ // GIVEN global state is reset at the start of the test
+ didTouchA = false
+ didTouchB = false
+ // WHEN starting 2 animations of different durations, and setting didTouchA at the end
+ ObjectAnimator.ofFloat(0f, 1f).apply {
+ duration = 100
+ doOnEnd { didTouchA = true }
+ start()
+ }
+ ObjectAnimator.ofFloat(0f, 1f).apply {
+ duration = 150
+ doOnEnd { didTouchA = true }
+ start()
+ }
+ // WHEN when you advance time so that only one of the animations has ended
+ animatorTestRule.advanceTimeBy(100)
+ // VERIFY we did indeed end the current animation
+ assertThat(didTouchA).isTrue()
+ // VERIFY advancing the animator did NOT cause testB's animator to end
+ assertThat(didTouchB).isFalse()
+ }
+
+ @Test
+ fun testB() {
+ // GIVEN global state is reset at the start of the test
+ didTouchA = false
+ didTouchB = false
+ // WHEN starting 2 animations of different durations, and setting didTouchB at the end
+ ObjectAnimator.ofFloat(0f, 1f).apply {
+ duration = 100
+ doOnEnd { didTouchB = true }
+ start()
+ }
+ ObjectAnimator.ofFloat(0f, 1f).apply {
+ duration = 150
+ doOnEnd { didTouchB = true }
+ start()
+ }
+ animatorTestRule.advanceTimeBy(100)
+ // VERIFY advancing the animator did NOT cause testA's animator to end
+ assertThat(didTouchA).isFalse()
+ // VERIFY we did indeed end the current animation
+ assertThat(didTouchB).isTrue()
+ }
+
+ companion object {
+ var didTouchA = false
+ var didTouchB = false
+ }
+}
diff --git a/packages/SystemUI/tests/src/android/animation/AnimatorTestRulePrecisionTest.kt b/packages/SystemUI/tests/src/android/animation/AnimatorTestRulePrecisionTest.kt
new file mode 100644
index 000000000000..6c4036852802
--- /dev/null
+++ b/packages/SystemUI/tests/src/android/animation/AnimatorTestRulePrecisionTest.kt
@@ -0,0 +1,193 @@
+/*
+ * 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.testing.AndroidTestingRunner
+import android.testing.TestableLooper.RunWithLooper
+import androidx.core.animation.doOnEnd
+import androidx.test.filters.SmallTest
+import com.android.app.animation.Interpolators
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+@RunWithLooper(setAsMainLooper = true)
+class AnimatorTestRulePrecisionTest : SysuiTestCase() {
+
+ @get:Rule val animatorTestRule = AnimatorTestRule()
+
+ var value1: Float = -1f
+ var value2: Float = -1f
+
+ private inline fun animateThis(
+ propertyName: String,
+ duration: Long,
+ startDelay: Long = 0,
+ crossinline onEndAction: (animator: Animator) -> Unit,
+ ) {
+ ObjectAnimator.ofFloat(this, propertyName, 0f, 1f).also {
+ it.interpolator = Interpolators.LINEAR
+ it.duration = duration
+ it.startDelay = startDelay
+ it.doOnEnd(onEndAction)
+ it.start()
+ }
+ }
+
+ @Test
+ fun testSingleAnimator() {
+ var ended = false
+ animateThis("value1", duration = 100) { ended = true }
+
+ assertThat(value1).isEqualTo(0f)
+ assertThat(ended).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(50)
+ assertThat(value1).isEqualTo(0.5f)
+ assertThat(ended).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(49)
+ assertThat(value1).isEqualTo(0.99f)
+ assertThat(ended).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(1)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(ended).isTrue()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(0)
+ }
+
+ @Test
+ fun testDelayedAnimator() {
+ var ended = false
+ animateThis("value1", duration = 100, startDelay = 50) { ended = true }
+
+ assertThat(value1).isEqualTo(-1f)
+ assertThat(ended).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(49)
+ assertThat(value1).isEqualTo(-1f)
+ assertThat(ended).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(1)
+ assertThat(value1).isEqualTo(0f)
+ assertThat(ended).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(99)
+ assertThat(value1).isEqualTo(0.99f)
+ assertThat(ended).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(1)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(ended).isTrue()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(0)
+ }
+
+ @Test
+ fun testTwoAnimators() {
+ var ended1 = false
+ var ended2 = false
+ animateThis("value1", duration = 100) { ended1 = true }
+ animateThis("value2", duration = 200) { ended2 = true }
+ assertThat(value1).isEqualTo(0f)
+ assertThat(value2).isEqualTo(0f)
+ assertThat(ended1).isFalse()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(2)
+
+ animatorTestRule.advanceTimeBy(99)
+ assertThat(value1).isEqualTo(0.99f)
+ assertThat(value2).isEqualTo(0.495f)
+ assertThat(ended1).isFalse()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(2)
+
+ animatorTestRule.advanceTimeBy(1)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(value2).isEqualTo(0.5f)
+ assertThat(ended1).isTrue()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(99)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(value2).isEqualTo(0.995f)
+ assertThat(ended1).isTrue()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(1)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(value2).isEqualTo(1f)
+ assertThat(ended1).isTrue()
+ assertThat(ended2).isTrue()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(0)
+ }
+
+ @Test
+ fun testChainedAnimators() {
+ var ended1 = false
+ var ended2 = false
+ animateThis("value1", duration = 100) {
+ ended1 = true
+ animateThis("value2", duration = 100) { ended2 = true }
+ }
+
+ assertThat(value1).isEqualTo(0f)
+ assertThat(value2).isEqualTo(-1f)
+ assertThat(ended1).isFalse()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(99)
+ assertThat(value1).isEqualTo(0.99f)
+ assertThat(value2).isEqualTo(-1f)
+ assertThat(ended1).isFalse()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(1)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(value2).isEqualTo(0f)
+ assertThat(ended1).isTrue()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(99)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(value2).isEqualTo(0.99f)
+ assertThat(ended1).isTrue()
+ assertThat(ended2).isFalse()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(1)
+
+ animatorTestRule.advanceTimeBy(1)
+ assertThat(value1).isEqualTo(1f)
+ assertThat(value2).isEqualTo(1f)
+ assertThat(ended1).isTrue()
+ assertThat(ended2).isTrue()
+ assertThat(AnimationHandler.getAnimationCount()).isEqualTo(0)
+ }
+}
diff --git a/packages/SystemUI/tests/src/androidx/core/animation/AnimatorTestRuleTest.kt b/packages/SystemUI/tests/src/androidx/core/animation/AnimatorTestRuleIsolationTest.kt
index e7738aff6278..d034093a71b3 100644
--- a/packages/SystemUI/tests/src/androidx/core/animation/AnimatorTestRuleTest.kt
+++ b/packages/SystemUI/tests/src/androidx/core/animation/AnimatorTestRuleIsolationTest.kt
@@ -25,17 +25,23 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+/**
+ * This test class validates that two tests' animators are isolated from each other when using the
+ * same animator test rule. This is a test to prevent future instances of b/275602127.
+ */
@RunWith(AndroidTestingRunner::class)
@SmallTest
@RunWithLooper(setAsMainLooper = true)
-class AnimatorTestRuleTest : SysuiTestCase() {
+class AnimatorTestRuleIsolationTest : SysuiTestCase() {
@get:Rule val animatorTestRule = AnimatorTestRule()
@Test
fun testA() {
+ // GIVEN global state is reset at the start of the test
didTouchA = false
didTouchB = false
+ // WHEN starting 2 animations of different durations, and setting didTouchA at the end
ObjectAnimator.ofFloat(0f, 1f).apply {
duration = 100
doOnEnd { didTouchA = true }
@@ -46,15 +52,20 @@ class AnimatorTestRuleTest : SysuiTestCase() {
doOnEnd { didTouchA = true }
start()
}
+ // WHEN when you advance time so that only one of the animations has ended
animatorTestRule.advanceTimeBy(100)
+ // VERIFY we did indeed end the current animation
assertThat(didTouchA).isTrue()
+ // VERIFY advancing the animator did NOT cause testB's animator to end
assertThat(didTouchB).isFalse()
}
@Test
fun testB() {
+ // GIVEN global state is reset at the start of the test
didTouchA = false
didTouchB = false
+ // WHEN starting 2 animations of different durations, and setting didTouchB at the end
ObjectAnimator.ofFloat(0f, 1f).apply {
duration = 100
doOnEnd { didTouchB = true }
@@ -66,7 +77,9 @@ class AnimatorTestRuleTest : SysuiTestCase() {
start()
}
animatorTestRule.advanceTimeBy(100)
+ // VERIFY advancing the animator did NOT cause testA's animator to end
assertThat(didTouchA).isFalse()
+ // VERIFY we did indeed end the current animation
assertThat(didTouchB).isTrue()
}
diff --git a/packages/SystemUI/tests/utils/src/android/animation/AnimatorIsolationWorkaroundRule.kt b/packages/SystemUI/tests/utils/src/android/animation/AnimatorIsolationWorkaroundRule.kt
new file mode 100644
index 000000000000..b74ddae77e62
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/android/animation/AnimatorIsolationWorkaroundRule.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.os.Looper
+import android.util.Log
+import com.android.systemui.util.test.TestExceptionDeferrer
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * This rule is intended to be used by System UI tests that are otherwise blocked from using
+ * animators because of [PlatformAnimatorIsolationRule]. It is preferred that test authors use
+ * [AnimatorTestRule], as that rule allows test authors to step through animations and removes the
+ * need for tests to handle multiple threads. However, many System UI tests were written before this
+ * was conceivable, so this rule is intended to support those legacy tests.
+ */
+class AnimatorIsolationWorkaroundRule(
+ private val requiredLooper: Looper? = Looper.getMainLooper(),
+) : TestRule {
+ private inner class IsolationWorkaroundHandler(ruleThread: Thread) : AnimationHandler() {
+ private val exceptionDeferrer = TestExceptionDeferrer(TAG, ruleThread)
+ private val addedCallbacks = mutableSetOf<AnimationFrameCallback>()
+
+ fun tearDownAndThrowDeferred() {
+ addedCallbacks.forEach { super.removeCallback(it) }
+ exceptionDeferrer.throwDeferred()
+ }
+
+ override fun addAnimationFrameCallback(callback: AnimationFrameCallback?, delay: Long) {
+ checkLooper()
+ if (callback != null) {
+ addedCallbacks.add(callback)
+ }
+ super.addAnimationFrameCallback(callback, delay)
+ }
+
+ override fun addOneShotCommitCallback(callback: AnimationFrameCallback?) {
+ checkLooper()
+ super.addOneShotCommitCallback(callback)
+ }
+
+ override fun removeCallback(callback: AnimationFrameCallback?) {
+ super.removeCallback(callback)
+ }
+
+ override fun setProvider(provider: AnimationFrameCallbackProvider?) {
+ checkLooper()
+ super.setProvider(provider)
+ }
+
+ override fun autoCancelBasedOn(objectAnimator: ObjectAnimator?) {
+ checkLooper()
+ super.autoCancelBasedOn(objectAnimator)
+ }
+
+ private fun checkLooper() {
+ exceptionDeferrer.check(requiredLooper == null || Looper.myLooper() == requiredLooper) {
+ "Animations are being registered on a different looper than the expected one!" +
+ " expected=$requiredLooper actual=${Looper.myLooper()}"
+ }
+ }
+ }
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return object : Statement() {
+ @Throws(Throwable::class)
+ override fun evaluate() {
+ val workaroundHandler = IsolationWorkaroundHandler(Thread.currentThread())
+ val prevInstance = AnimationHandler.setTestHandler(workaroundHandler)
+ check(PlatformAnimatorIsolationRule.isIsolatingHandler(prevInstance)) {
+ "AnimatorIsolationWorkaroundRule must be used within " +
+ "PlatformAnimatorIsolationRule, but test handler was $prevInstance"
+ }
+ try {
+ base.evaluate()
+ val count = AnimationHandler.getAnimationCount()
+ if (count > 0) {
+ Log.w(TAG, "Animations still running: $count")
+ }
+ } finally {
+ val handlerAtEnd = AnimationHandler.setTestHandler(prevInstance)
+ check(workaroundHandler == handlerAtEnd) {
+ "Test handler was altered: expected=$workaroundHandler actual=$handlerAtEnd"
+ }
+ // Pass or fail, errors caught here should be the reason the test fails
+ workaroundHandler.tearDownAndThrowDeferred()
+ }
+ }
+ }
+ }
+
+ private companion object {
+ private const val TAG = "AnimatorIsolationWorkaroundRule"
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/android/animation/AnimatorTestRule.java b/packages/SystemUI/tests/utils/src/android/animation/AnimatorTestRule.java
new file mode 100644
index 000000000000..6535f333f428
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/android/animation/AnimatorTestRule.java
@@ -0,0 +1,236 @@
+/*
+ * 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.os.Looper;
+import android.os.SystemClock;
+import android.util.AndroidRuntimeException;
+import android.view.Choreographer;
+
+import com.android.internal.util.Preconditions;
+
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 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.
+ *
+ * <pre>
+ * {@literal @}SmallTest
+ * {@literal @}RunWith(AndroidJUnit4.class)
+ * public class SampleAnimatorTest {
+ *
+ * {@literal @}Rule
+ * public AnimatorTestRule sAnimatorTestRule = 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();
+ * sAnimatorTestRule.advanceTimeBy(500L);
+ * assertThat(animator.getAnimatedValue(), is(500));
+ * }
+ * }
+ * </pre>
+ */
+public final class AnimatorTestRule implements TestRule {
+
+ private final Object mLock = new Object();
+ private final TestHandler mTestHandler = new TestHandler();
+ /**
+ * initializing the start time with {@link SystemClock#uptimeMillis()} reduces the discrepancies
+ * with various internals of classes like ValueAnimator which can sometimes read that clock via
+ * {@link android.view.animation.AnimationUtils#currentAnimationTimeMillis()}.
+ */
+ private final long mStartTime = SystemClock.uptimeMillis();
+ private long mTotalTimeDelta = 0;
+
+ @NonNull
+ @Override
+ public Statement apply(@NonNull final Statement base, @NonNull Description description) {
+ return new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ AnimationHandler objAtStart = AnimationHandler.setTestHandler(mTestHandler);
+ try {
+ base.evaluate();
+ } finally {
+ AnimationHandler objAtEnd = AnimationHandler.setTestHandler(objAtStart);
+ if (mTestHandler != objAtEnd) {
+ // pass or fail, inner logic not restoring the handler needs to be reported.
+ // noinspection ThrowFromFinallyBlock
+ throw new IllegalStateException("Test handler was altered: expected="
+ + mTestHandler + " actual=" + objAtEnd);
+ }
+ }
+ }
+ };
+ }
+
+ /**
+ * 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.
+ * <p>
+ * 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();
+ List<AnimationFrameCallback> newCallbacks = new ArrayList<>(mTestHandler.mNewCallbacks);
+ mTestHandler.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) {
+ Preconditions.checkArgumentNonnegative(timeDelta, "timeDelta must not be negative");
+ requireLooper("AnimationTestRule#advanceTimeBy(long)");
+ // before advancing time, start new animators with the current time
+ initNewAnimators();
+ synchronized (mLock) {
+ // advance time
+ mTotalTimeDelta += timeDelta;
+ }
+ // produce a frame
+ mTestHandler.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<AnimationFrameCallback> 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<Runnable> mCommitCallbacks = new ArrayList<>();
+
+ public void animateFrame() {
+ Choreographer.FrameCallback frameCallback = mFrameCallback;
+ mFrameCallback = null;
+ if (frameCallback != null) {
+ frameCallback.doFrame(getFrameTime());
+ }
+ }
+
+ public void commitFrame() {
+ List<Runnable> 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();
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/android/animation/PlatformAnimatorIsolationRule.kt b/packages/SystemUI/tests/utils/src/android/animation/PlatformAnimatorIsolationRule.kt
new file mode 100644
index 000000000000..43a26f34ef2e
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/android/animation/PlatformAnimatorIsolationRule.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 com.android.systemui.util.test.TestExceptionDeferrer
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * This rule is used by [com.android.systemui.SysuiTestCase] to fail any test which attempts to
+ * start a Platform [Animator] without using [android.animation.AnimatorTestRule].
+ *
+ * TODO(b/291645410): enable this; currently this causes hundreds of test failures.
+ */
+class PlatformAnimatorIsolationRule : TestRule {
+
+ private class IsolatingAnimationHandler(ruleThread: Thread) : AnimationHandler() {
+ private val exceptionDeferrer = TestExceptionDeferrer(TAG, ruleThread)
+ override fun addOneShotCommitCallback(callback: AnimationFrameCallback?) = onError()
+ override fun removeCallback(callback: AnimationFrameCallback?) = onError()
+ override fun setProvider(provider: AnimationFrameCallbackProvider?) = onError()
+ override fun autoCancelBasedOn(objectAnimator: ObjectAnimator?) = onError()
+ override fun addAnimationFrameCallback(callback: AnimationFrameCallback?, delay: Long) =
+ onError()
+
+ private fun onError() =
+ exceptionDeferrer.fail(
+ "Test's animations are not isolated! " +
+ "Did you forget to add an AnimatorTestRule to your test class?"
+ )
+
+ fun throwDeferred() = exceptionDeferrer.throwDeferred()
+ }
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return object : Statement() {
+ @Throws(Throwable::class)
+ override fun evaluate() {
+ val isolationHandler = IsolatingAnimationHandler(Thread.currentThread())
+ val originalHandler = AnimationHandler.setTestHandler(isolationHandler)
+ try {
+ base.evaluate()
+ } finally {
+ val handlerAtEnd = AnimationHandler.setTestHandler(originalHandler)
+ check(isolationHandler == handlerAtEnd) {
+ "Test handler was altered: expected=$isolationHandler actual=$handlerAtEnd"
+ }
+ // Pass or fail, a deferred exception should be the failure reason
+ isolationHandler.throwDeferred()
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "PlatformAnimatorIsolationRule"
+
+ fun isIsolatingHandler(handler: AnimationHandler?): Boolean =
+ handler is IsolatingAnimationHandler
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/androidx/core/animation/AndroidXAnimatorIsolationRule.kt b/packages/SystemUI/tests/utils/src/androidx/core/animation/AndroidXAnimatorIsolationRule.kt
index 026372f64e2c..7a97029ca6b0 100644
--- a/packages/SystemUI/tests/utils/src/androidx/core/animation/AndroidXAnimatorIsolationRule.kt
+++ b/packages/SystemUI/tests/utils/src/androidx/core/animation/AndroidXAnimatorIsolationRule.kt
@@ -16,40 +16,51 @@
package androidx.core.animation
+import com.android.systemui.util.test.TestExceptionDeferrer
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
+/**
+ * This rule is used by [com.android.systemui.SysuiTestCase] to fail any test which attempts to
+ * start an AndroidX [Animator] without using [androidx.core.animation.AnimatorTestRule].
+ */
class AndroidXAnimatorIsolationRule : TestRule {
- private class TestAnimationHandler : AnimationHandler(null) {
- override fun addAnimationFrameCallback(callback: AnimationFrameCallback?) = doFail()
- override fun removeCallback(callback: AnimationFrameCallback?) = doFail()
- override fun onAnimationFrame(frameTime: Long) = doFail()
- override fun setFrameDelay(frameDelay: Long) = doFail()
- override fun getFrameDelay(): Long = doFail()
+ private class IsolatingAnimationHandler(ruleThread: Thread) : AnimationHandler(null) {
+ private val exceptionDeferrer = TestExceptionDeferrer(TAG, ruleThread)
+ override fun addAnimationFrameCallback(callback: AnimationFrameCallback?) = onError()
+ override fun removeCallback(callback: AnimationFrameCallback?) = onError()
+ override fun onAnimationFrame(frameTime: Long) = onError()
+ override fun setFrameDelay(frameDelay: Long) = onError()
+
+ private fun onError() =
+ exceptionDeferrer.fail(
+ "Test's animations are not isolated! " +
+ "Did you forget to add an AnimatorTestRule to your test class?"
+ )
+
+ fun throwDeferred() = exceptionDeferrer.throwDeferred()
}
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
- AnimationHandler.setTestHandler(testHandler)
+ val isolationHandler = IsolatingAnimationHandler(Thread.currentThread())
+ AnimationHandler.setTestHandler(isolationHandler)
try {
base.evaluate()
} finally {
AnimationHandler.setTestHandler(null)
+ // Pass or fail, a deferred exception should be the failure reason
+ isolationHandler.throwDeferred()
}
}
}
}
- companion object {
- private val testHandler = TestAnimationHandler()
- private fun doFail(): Nothing =
- error(
- "Test's animations are not isolated! " +
- "Did you forget to add an AnimatorTestRule to your test class?"
- )
+ private companion object {
+ private const val TAG = "AndroidXAnimatorIsolationRule"
}
}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/test/TestExceptionDeferrer.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/test/TestExceptionDeferrer.kt
new file mode 100644
index 000000000000..90281ca7c2e7
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/test/TestExceptionDeferrer.kt
@@ -0,0 +1,56 @@
+/*
+ * 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 com.android.systemui.util.test
+
+import android.util.Log
+
+/**
+ * Helper class that intercepts test errors which may be occurring on the wrong thread, and saves
+ * them so that they can be rethrown back on the correct thread.
+ */
+class TestExceptionDeferrer(private val tag: String, private val testThread: Thread) {
+ private val deferredErrors = mutableListOf<IllegalStateException>()
+
+ /** Ensure the [value] is `true`; otherwise [fail] with the produced [message] */
+ fun check(value: Boolean, message: () -> Any?) {
+ if (value) return
+ fail(message().toString())
+ }
+
+ /**
+ * If the [Thread.currentThread] is the [testThread], then [error], otherwise [Log] and defer
+ * the error until [throwDeferred] is called.
+ */
+ fun fail(message: String) {
+ if (testThread == Thread.currentThread()) {
+ error(message)
+ } else {
+ val exception = IllegalStateException(message)
+ Log.e(tag, "Deferring error: ", exception)
+ deferredErrors.add(exception)
+ }
+ }
+
+ /** If any [fail] or failed [check] has happened, throw the first one. */
+ fun throwDeferred() {
+ deferredErrors.firstOrNull()?.let { firstError ->
+ Log.e(tag, "Deferred errors: ${deferredErrors.size}")
+ deferredErrors.clear()
+ throw firstError
+ }
+ }
+}