diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ee3e6d1c72..2757b9d681 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -85,6 +85,9 @@ standard MIDI files using the Jsyn library to synthesize audio. * Cast Extension: * Test Utilities: + * Make `TestExoPlayerBuilder` and `FakeClock` compatible with Espresso UI + tests and Compose UI tests. This fixes a bug where playback advances + non-deterministically during Espresso or Compose view interactions. * Remove deprecated symbols: ## 1.1 diff --git a/constants.gradle b/constants.gradle index c09b0d3034..cd7a130e02 100644 --- a/constants.gradle +++ b/constants.gradle @@ -52,6 +52,7 @@ project.ext { androidxRecyclerViewVersion = '1.3.0' androidxMaterialVersion = '1.8.0' androidxTestCoreVersion = '1.5.0' + androidxTestEspressoVersion = '3.5.1' androidxTestJUnitVersion = '1.1.5' androidxTestRunnerVersion = '1.5.2' androidxTestRulesVersion = '1.5.0' diff --git a/libraries/test_utils/build.gradle b/libraries/test_utils/build.gradle index 663864ed31..d888ff2c5d 100644 --- a/libraries/test_utils/build.gradle +++ b/libraries/test_utils/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion implementation 'com.squareup.okhttp3:mockwebserver:' + okhttpVersion implementation project(modulePrefix + 'lib-exoplayer') + testImplementation 'androidx.test.espresso:espresso-core:' + androidxTestEspressoVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java index db37f66405..c962d87bcf 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeClock.java @@ -28,6 +28,7 @@ import androidx.media3.common.util.Clock; import androidx.media3.common.util.HandlerWrapper; import androidx.media3.common.util.UnstableApi; import com.google.common.collect.ComparisonChain; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Ordering; import java.util.ArrayList; import java.util.Collections; @@ -52,10 +53,20 @@ import java.util.Set; @UnstableApi public class FakeClock implements Clock { + private static final ImmutableSet UI_INTERACTION_TEST_CLASSES = + ImmutableSet.of( + "org.robolectric.android.internal.LocalControlledLooper", + "androidx.test.core.app.ActivityScenario", + "org.robolectric.android.controller.ActivityController"); + private static final String ROBOLECTRIC_SHADOW_LOOPER_CLASS = + "org.robolectric.shadows.ShadowPausedLooper"; + private static final String ROBOLECTRIC_SHADOW_LOOPER_IDLE_METHOD = "idle"; + private static long messageIdProvider = 0; private final boolean isRobolectric; private final boolean isAutoAdvancing; + private final Handler mainHandler; @GuardedBy("this") private final List handlerMessages; @@ -121,6 +132,7 @@ public class FakeClock implements Clock { this.isAutoAdvancing = isAutoAdvancing; this.handlerMessages = new ArrayList<>(); this.busyLoopers = new HashSet<>(); + this.mainHandler = new Handler(Looper.getMainLooper()); this.isRobolectric = "robolectric".equals(Build.FINGERPRINT); if (isRobolectric) { SystemClock.setCurrentTimeMillis(initialTimeMs); @@ -235,6 +247,18 @@ public class FakeClock implements Clock { } message = handlerMessages.get(messageIndex); } + if (message.handler.getLooper() == Looper.getMainLooper() && isIdlingInUiInteraction()) { + // UI interaction tests idle the main looper and may trigger almost infinite progress in the + // player. Avoid this situation by postponing any further updates on the main looper to after + // the UI interaction. + Looper.myQueue() + .addIdleHandler( + () -> { + mainHandler.postDelayed(this::maybeTriggerMessage, /* delayMillis= */ 1); + return false; + }); + return; + } if (message.timeMs > timeSinceBootMs) { if (isAutoAdvancing) { advanceTimeInternal(message.timeMs - timeSinceBootMs); @@ -276,6 +300,25 @@ public class FakeClock implements Clock { return messageIdProvider++; } + private static boolean isIdlingInUiInteraction() { + if (Looper.myLooper() != Looper.getMainLooper()) { + return false; + } + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + boolean isIdling = false; + boolean isInUiInteraction = false; + for (StackTraceElement element : stackTrace) { + if (UI_INTERACTION_TEST_CLASSES.contains(element.getClassName())) { + isInUiInteraction = true; + } + if (element.getClassName().equals(ROBOLECTRIC_SHADOW_LOOPER_CLASS) + && element.getMethodName().equals(ROBOLECTRIC_SHADOW_LOOPER_IDLE_METHOD)) { + isIdling = true; + } + } + return isIdling && isInUiInteraction; + } + /** Message data saved to send messages or execute runnables at a later time on a Handler. */ protected final class HandlerMessage implements Comparable, HandlerWrapper.Message { diff --git a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java index a63d27bbbd..8bef1456e1 100644 --- a/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java +++ b/libraries/test_utils/src/test/java/androidx/media3/test/utils/FakeClockTest.java @@ -15,13 +15,20 @@ */ package androidx.media3.test.utils; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; import static com.google.common.truth.Truth.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.robolectric.Shadows.shadowOf; +import android.app.Activity; +import android.os.Bundle; import android.os.ConditionVariable; import android.os.Handler; import android.os.HandlerThread; +import android.os.Looper; import android.os.Message; +import android.widget.Button; import androidx.annotation.Nullable; import androidx.media3.common.util.HandlerWrapper; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -32,6 +39,8 @@ import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.android.controller.ActivityController; import org.robolectric.shadows.ShadowLooper; /** Unit test for {@link FakeClock}. */ @@ -416,6 +425,39 @@ public final class FakeClockTest { assertThat(messageOnDeadThreadExecuted.get()).isFalse(); } + @Test + public void espressoViewInteraction_doesNotHandleDelayedPendingMessages() { + try (ActivityController activityController = + Robolectric.buildActivity(TestActivity.class)) { + TestActivity activity = activityController.setup().get(); + FakeClock fakeClock = new FakeClock(/* initialTimeMs= */ 0, /* isAutoAdvancing= */ true); + AtomicBoolean delayedChange = new AtomicBoolean(); + fakeClock + .createHandler(Looper.myLooper(), /* callback= */ null) + .postDelayed(() -> delayedChange.set(true), /* delayMs= */ 50); + + onView(equalTo(activity.button)).perform(click()); + + assertThat(delayedChange.get()).isFalse(); + + // Verify test setup that the delayed message gets executed with manually triggered progress. + ShadowLooper.runMainLooperToNextTask(); + assertThat(delayedChange.get()).isTrue(); + } + } + + private static class TestActivity extends Activity { + + public Button button; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + button = new Button(this); + setContentView(button); + } + } + private static void assertTestRunnableStates(boolean[] states, TestRunnable[] testRunnables) { for (int i = 0; i < testRunnables.length; i++) { assertThat(testRunnables[i].hasRun).isEqualTo(states[i]);