diff --git a/RELEASENOTES.md b/RELEASENOTES.md index f61c591b73..62a0143a87 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -120,6 +120,9 @@ media fails on the cast device ([#708](https://github.com/androidx/media/issues/708)). * Test Utilities: + * Don't pause playback in `TestPlayerRunHelper.playUntilPosition`. The + test keeps the playback in a playing state, but suspends progress until + the test is able to add assertions and further actions. * Remove deprecated symbols: * Demo app: * Add a shortform demo module to demo the usage of `PreloadMediaSource` diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java index c1421266ba..72cbc60b29 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java @@ -547,10 +547,8 @@ public final class DefaultAnalyticsCollectorTest { WINDOW_0 /* BUFFERING */, period0 /* READY */, period0 /* setPlayWhenReady=true */, - period0 /* setPlayWhenReady=false */, period0 /* BUFFERING */, period0 /* READY */, - period0 /* setPlayWhenReady=true */, period1Seq2 /* ENDED */) .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) @@ -829,10 +827,8 @@ public final class DefaultAnalyticsCollectorTest { WINDOW_0 /* BUFFERING */, window0Period1Seq0 /* READY */, window0Period1Seq0 /* setPlayWhenReady=true */, - window0Period1Seq0 /* setPlayWhenReady=false */, period1Seq0 /* BUFFERING */, period1Seq0 /* READY */, - period1Seq0 /* setPlayWhenReady=true */, period1Seq0 /* ENDED */) .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) @@ -1107,10 +1103,6 @@ public final class DefaultAnalyticsCollectorTest { WINDOW_0 /* BUFFERING */, prerollAd /* READY */, prerollAd /* setPlayWhenReady=true */, - contentAfterPreroll /* setPlayWhenReady=false */, - contentAfterPreroll /* setPlayWhenReady=true */, - contentAfterMidroll /* setPlayWhenReady=false */, - contentAfterMidroll /* setPlayWhenReady=true */, contentAfterPostroll /* ENDED */) .inOrder(); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) @@ -1186,8 +1178,7 @@ public final class DefaultAnalyticsCollectorTest { contentAfterPostroll) .inOrder(); assertThat(listener.getEvents(EVENT_DROPPED_VIDEO_FRAMES)) - .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll) - .inOrder(); + .containsExactly(contentAfterPostroll); assertThat(listener.getEvents(EVENT_VIDEO_SIZE_CHANGED)) .containsExactly(prerollAd) // First frame rendered .inOrder(); @@ -1201,8 +1192,7 @@ public final class DefaultAnalyticsCollectorTest { contentAfterPostroll) .inOrder(); assertThat(listener.getEvents(EVENT_VIDEO_FRAME_PROCESSING_OFFSET)) - .containsExactly(contentAfterPreroll, contentAfterMidroll, contentAfterPostroll) - .inOrder(); + .containsExactly(contentAfterPostroll); listener.assertNoMoreEvents(); } 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 9781ce42b7..c56fbbedd4 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 @@ -182,7 +182,8 @@ public class FakeClock implements Clock { // This isn't a looper message created by this class, so no need to handle the blocking. return; } - busyLoopers.add(checkNotNull(Looper.myLooper())); + busyLoopers.add(currentLooper); + ThreadTestUtil.unblockThreadsWaitingForProgressOnCurrentLooper(); waitingForMessage = false; maybeTriggerMessage(); } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/ThreadTestUtil.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/ThreadTestUtil.java new file mode 100644 index 0000000000..6ad4f891d9 --- /dev/null +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/ThreadTestUtil.java @@ -0,0 +1,61 @@ +/* + * Copyright 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 androidx.media3.test.utils; + +import android.os.Looper; +import androidx.annotation.GuardedBy; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.ConditionVariable; +import androidx.media3.common.util.UnstableApi; +import com.google.common.collect.ArrayListMultimap; + +/** Static utility to coordinate threads in testing environments. */ +@UnstableApi +public final class ThreadTestUtil { + + @GuardedBy("blockedThreadConditions") + private static final ArrayListMultimap blockedThreadConditions = + ArrayListMultimap.create(); + + /** + * Registers that the current thread will be blocked with the provided {@link ConditionVariable} + * until the specified {@link Looper} reports to have made progress via {@link + * #unblockThreadsWaitingForProgressOnCurrentLooper()}. + * + * @param conditionVariable The {@link ConditionVariable} that will block the current thread. + * @param looper The {@link Looper} that must report progress to unblock the current thread. Must + * not be the {@link Looper} of the current thread. + */ + public static void registerThreadIsBlockedUntilProgressOnLooper( + ConditionVariable conditionVariable, Looper looper) { + Assertions.checkArgument(looper != Looper.myLooper()); + synchronized (blockedThreadConditions) { + blockedThreadConditions.put(looper, conditionVariable); + } + } + + /** Unblocks any threads that are waiting for progress on the current {@link Looper} thread. */ + public static void unblockThreadsWaitingForProgressOnCurrentLooper() { + Looper myLooper = Assertions.checkNotNull(Looper.myLooper()); + synchronized (blockedThreadConditions) { + for (ConditionVariable condition : blockedThreadConditions.removeAll(myLooper)) { + condition.open(); + } + } + } + + private ThreadTestUtil() {} +} 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 8bef1456e1..6fcd186d4b 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 @@ -23,13 +23,13 @@ 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.ConditionVariable; import androidx.media3.common.util.HandlerWrapper; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.base.Objects; @@ -322,7 +322,8 @@ public final class FakeClockTest { } @Test - public void createHandler_multiThreadCommunication_deliversMessagesDeterministicallyInOrder() { + public void createHandler_multiThreadCommunication_deliversMessagesDeterministicallyInOrder() + throws Exception { HandlerThread handlerThread1 = new HandlerThread("FakeClockTest"); handlerThread1.start(); HandlerThread handlerThread2 = new HandlerThread("FakeClockTest"); @@ -362,7 +363,7 @@ public final class FakeClockTest { } @Test - public void createHandler_blockingThreadWithOnBusyWaiting_canBeUnblockedByOtherThread() { + public void createHandler_blockingThreadWithOnThreadBlocked_canBeUnblockedByOtherThread() { HandlerThread handlerThread1 = new HandlerThread("FakeClockTest"); handlerThread1.start(); HandlerThread handlerThread2 = new HandlerThread("FakeClockTest"); @@ -386,7 +387,11 @@ public final class FakeClockTest { /* delayMs= */ 50); handler1.post(() -> executionOrder.add(4)); fakeClock.onThreadBlocked(); - blockingCondition.block(); + try { + blockingCondition.block(); + } catch (InterruptedException e) { + // Ignore. + } executionOrder.add(3); }); ShadowLooper.idleMainLooper(); @@ -399,7 +404,102 @@ public final class FakeClockTest { } @Test - public void createHandler_messageOnDeadThread_doesNotBlockExecution() { + public void + createHandler_blockingThreadUntilProgressOnLooperWithOnThreadBlocked_canBeUnblockedByOtherThread() { + HandlerThread handlerThread1 = new HandlerThread("FakeClockTest"); + handlerThread1.start(); + HandlerThread handlerThread2 = new HandlerThread("FakeClockTest"); + handlerThread2.start(); + FakeClock fakeClock = new FakeClock(/* initialTimeMs= */ 0, /* isAutoAdvancing= */ true); + HandlerWrapper handler1 = + fakeClock.createHandler(handlerThread1.getLooper(), /* callback= */ null); + HandlerWrapper handler2 = + fakeClock.createHandler(handlerThread2.getLooper(), /* callback= */ null); + + ArrayList executionOrder = new ArrayList<>(); + handler1.post( + () -> { + executionOrder.add(1); + ConditionVariable blockingCondition = new ConditionVariable(); + handler2.postDelayed( + () -> { + executionOrder.add(2); + ThreadTestUtil.unblockThreadsWaitingForProgressOnCurrentLooper(); + }, + /* delayMs= */ 100); + handler1.post(() -> executionOrder.add(4)); + ThreadTestUtil.registerThreadIsBlockedUntilProgressOnLooper( + blockingCondition, handlerThread2.getLooper()); + fakeClock.onThreadBlocked(); + try { + blockingCondition.block(); + } catch (InterruptedException e) { + // Ignore. + } + executionOrder.add(3); + }); + ShadowLooper.idleMainLooper(); + shadowOf(handler1.getLooper()).idle(); + shadowOf(handler2.getLooper()).idle(); + handlerThread1.quitSafely(); + handlerThread2.quitSafely(); + + assertThat(executionOrder).containsExactly(1, 2, 3, 4).inOrder(); + } + + @Test + public void createHandler_blockingDeadlock_unblocksItself() { + HandlerThread handlerThread1 = new HandlerThread("FakeClockTest"); + handlerThread1.start(); + HandlerThread handlerThread2 = new HandlerThread("FakeClockTest"); + handlerThread2.start(); + FakeClock fakeClock = new FakeClock(/* initialTimeMs= */ 0, /* isAutoAdvancing= */ true); + HandlerWrapper handler1 = + fakeClock.createHandler(handlerThread1.getLooper(), /* callback= */ null); + HandlerWrapper handler2 = + fakeClock.createHandler(handlerThread2.getLooper(), /* callback= */ null); + + ArrayList executionOrder = new ArrayList<>(); + + handler1.post( + () -> { + executionOrder.add(1); + ConditionVariable deadlockCondition1 = new ConditionVariable(); + ConditionVariable deadlockCondition2 = new ConditionVariable(); + handler2.postDelayed( + () -> { + executionOrder.add(2); + fakeClock.onThreadBlocked(); + try { + deadlockCondition2.block(); + } catch (InterruptedException e) { + // Ignore. + } + }, + /* delayMs= */ 100); + handler1.post(() -> executionOrder.add(4)); + ThreadTestUtil.registerThreadIsBlockedUntilProgressOnLooper( + deadlockCondition1, handlerThread2.getLooper()); + fakeClock.onThreadBlocked(); + try { + deadlockCondition1.block(); + } catch (InterruptedException e) { + // Ignore. + } + executionOrder.add(3); + deadlockCondition2.open(); + }); + ShadowLooper.idleMainLooper(); + shadowOf(handler1.getLooper()).idle(); + shadowOf(handler2.getLooper()).idle(); + handlerThread1.quitSafely(); + handlerThread2.quitSafely(); + + assertThat(executionOrder).containsExactly(1, 2, 3, 4).inOrder(); + } + + @Test + public void createHandler_messageOnDeadThread_doesNotBlockExecution() throws Exception { HandlerThread handlerThread1 = new HandlerThread("FakeClockTest"); handlerThread1.start(); HandlerThread handlerThread2 = new HandlerThread("FakeClockTest"); diff --git a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/RobolectricUtil.java b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/RobolectricUtil.java index 32f2c01b9e..bff6778b3f 100644 --- a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/RobolectricUtil.java +++ b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/RobolectricUtil.java @@ -22,6 +22,7 @@ import androidx.media3.common.util.Clock; import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.SystemClock; import androidx.media3.common.util.UnstableApi; +import androidx.media3.test.utils.ThreadTestUtil; import com.google.common.base.Supplier; import java.util.concurrent.TimeoutException; import org.robolectric.shadows.ShadowLooper; @@ -116,6 +117,7 @@ public final class RobolectricUtil { if (Looper.myLooper() != looper) { throw new IllegalStateException(); } + ThreadTestUtil.unblockThreadsWaitingForProgressOnCurrentLooper(); ShadowLooper shadowLooper = shadowOf(looper); long timeoutTimeMs = clock.currentTimeMillis() + timeoutMs; while (!condition.get()) { diff --git a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java index fb155f2d91..1e15c89535 100644 --- a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java +++ b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java @@ -29,6 +29,7 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.exoplayer.ExoPlaybackException; import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.test.utils.ThreadTestUtil; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -289,7 +290,12 @@ public class TestPlayerRunHelper { /** * Calls {@link Player#play()}, runs tasks of the main {@link Looper} until the {@code player} - * reaches the specified position or a playback error occurs, and then pauses the {@code player}. + * reaches the specified position or a playback error occurs. + * + *

The playback thread is automatically blocked from making further progress after reaching + * this position and will only be unblocked by other {@code run/playUntil...} methods, custom + * {@link RobolectricUtil#runMainLooperUntil} conditions or an explicit {@link + * ThreadTestUtil#unblockThreadsWaitingForProgressOnCurrentLooper()} on the main thread. * *

If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}. * @@ -308,17 +314,14 @@ public class TestPlayerRunHelper { player .createMessage( (messageType, payload) -> { - // Block playback thread until pause command has been sent from test thread. + // Block playback thread until the main app thread is able to trigger further actions. ConditionVariable blockPlaybackThreadCondition = new ConditionVariable(); + ThreadTestUtil.registerThreadIsBlockedUntilProgressOnLooper( + blockPlaybackThreadCondition, applicationLooper); player .getClock() .createHandler(applicationLooper, /* callback= */ null) - .post( - () -> { - player.pause(); - messageHandled.set(true); - blockPlaybackThreadCondition.open(); - }); + .post(() -> messageHandled.set(true)); try { player.getClock().onThreadBlocked(); blockPlaybackThreadCondition.block(); diff --git a/libraries/test_utils_robolectric/src/test/AndroidManifest.xml b/libraries/test_utils_robolectric/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..679023e230 --- /dev/null +++ b/libraries/test_utils_robolectric/src/test/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/libraries/test_utils_robolectric/src/test/java/androidx/media3/test/utils/robolectric/RobolectricUtilTest.java b/libraries/test_utils_robolectric/src/test/java/androidx/media3/test/utils/robolectric/RobolectricUtilTest.java index 3f90ee7848..5c3b5de8ee 100644 --- a/libraries/test_utils_robolectric/src/test/java/androidx/media3/test/utils/robolectric/RobolectricUtilTest.java +++ b/libraries/test_utils_robolectric/src/test/java/androidx/media3/test/utils/robolectric/RobolectricUtilTest.java @@ -23,8 +23,10 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.os.Looper; import androidx.media3.common.util.Clock; import androidx.media3.common.util.ConditionVariable; +import androidx.media3.test.utils.ThreadTestUtil; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.base.Supplier; import java.util.concurrent.TimeoutException; @@ -90,4 +92,35 @@ public class RobolectricUtilTest { verify(mockCondition, times(5)).get(); } + + @Test + public void + runMainLooperUntil_whenConditionIsBlockedOnOtherThreadWaitingForProgress_unblocksItself() + throws Exception { + Clock mockClock = mock(Clock.class); + ConditionVariable testCondition = new ConditionVariable(); + ConditionVariable testThreadReady = new ConditionVariable(); + Thread thread = + new Thread("RobolectricUtilsTest") { + @Override + public void run() { + ConditionVariable blockedCondition = new ConditionVariable(); + ThreadTestUtil.registerThreadIsBlockedUntilProgressOnLooper( + blockedCondition, Looper.getMainLooper()); + testThreadReady.open(); + try { + blockedCondition.block(); + } catch (InterruptedException e) { + // Ignore. + } + testCondition.open(); + } + }; + thread.start(); + testThreadReady.block(); + + // Verify the thread gets unblocked. + RobolectricUtil.runMainLooperUntil(testCondition::isOpen, /* timeoutMs= */ 42, mockClock); + thread.join(); + } }