Keep playback thread blocked until next action with playUntilPosition

We currently pause playback to prevent further progress while the
app thread runs assertion and triggers additional actions. This is
not ideal because we do not actually want to pause playback in
almost all cases where this method used.

This can be improved by keeping the playback thread blocked and only
unblock it the next time the app thread waits for the player (either
via RobolectricUtil methods or by blocking the thread itself). To
add this automatic handling, this change introduces a new util class
for the tests that can keep the list of waiting threads statically
(because the access to this logic is spread across multiple independent
classes).

PiperOrigin-RevId: 589784204
This commit is contained in:
tonihei 2023-12-11 04:42:42 -08:00 committed by Copybara-Service
parent 00d5b6ec99
commit 00c7a9bcbb
9 changed files with 238 additions and 26 deletions

View File

@ -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`

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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<Looper, ConditionVariable> 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() {}
}

View File

@ -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<Integer> 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<Integer> 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");

View File

@ -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()) {

View File

@ -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.
*
* <p>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.
*
* <p>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();

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<manifest package="androidx.media3.test.utils.robolectric.test">
<uses-sdk/>
</manifest>

View File

@ -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();
}
}