diff options
| author | 2023-08-03 18:30:42 +0000 | |
|---|---|---|
| committer | 2023-08-03 18:30:42 +0000 | |
| commit | bb3e034bd0c7f28bece2b10e48851aab152a2878 (patch) | |
| tree | 9042cd200e27be908d3dc294d0be029ee9630ab9 | |
| parent | ff40cef9914301eac986ec451d26c870fffc0f4d (diff) | |
| parent | 705838998102826ba28144463f654e5006963d05 (diff) | |
Merge "Add AnimatorTestRule for use with platform animators" into udc-qpr-dev
| -rw-r--r-- | core/java/android/animation/AnimationHandler.java | 21 | ||||
| -rw-r--r-- | packages/SystemUI/tests/src/android/animation/AnimatorTestRuleIsolationTest.kt | 90 | ||||
| -rw-r--r-- | packages/SystemUI/tests/src/android/animation/AnimatorTestRulePrecisionTest.kt | 193 | ||||
| -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.kt | 111 | ||||
| -rw-r--r-- | packages/SystemUI/tests/utils/src/android/animation/AnimatorTestRule.java | 236 | ||||
| -rw-r--r-- | packages/SystemUI/tests/utils/src/android/animation/PlatformAnimatorIsolationRule.kt | 76 | ||||
| -rw-r--r-- | packages/SystemUI/tests/utils/src/androidx/core/animation/AndroidXAnimatorIsolationRule.kt | 39 | ||||
| -rw-r--r-- | packages/SystemUI/tests/utils/src/com/android/systemui/util/test/TestExceptionDeferrer.kt | 56 |
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 + } + } +} |