Update TestPlayerRunHelper to fail on non-fatal errors by default

Also introduce a fluent API that allows callers to ignore non-fatal
errors (while avoiding adding boolean overloads for every method).

**Most** tests want to fail on non-fatal errors (since they likely
indicate user-visible issues like codec errors etc), only tests
explicitly testing fallback in error scenarios should want to ignore
them.

Before this change there were a few `playUntilXXX` methods. These can
now all be triggered via `play(player).untilXXX`, which means
effectively every 'until' condition is available in a 'play until'
variant that calls `play` just before waiting for the condition.

PiperOrigin-RevId: 608988234
This commit is contained in:
ibaker 2024-02-21 07:36:34 -08:00 committed by Copybara-Service
parent 2e9cc2784f
commit bb5c688543
2 changed files with 652 additions and 178 deletions

View File

@ -50,6 +50,11 @@
* Cast Extension:
* Test Utilities:
* Implement `onInit()` and `onRelease()` in `FakeRenderer`.
* Change `TestPlayerRunHelper.runUntil/playUntil` methods to fail on
non-fatal errors (e.g. those reported to
`AnalyticsListener.onVideoCodecError`). Use the new
`TestPlayerRunHelper.run(player).ignoringNonFatalErrors().untilXXX()`
method chain to disable this behavior.
* Remove deprecated symbols:
* Demo app:

View File

@ -21,6 +21,8 @@ import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil;
import android.os.Looper;
import androidx.annotation.Nullable;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.ConditionVariable;
@ -29,25 +31,451 @@ 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.exoplayer.analytics.AnalyticsListener;
import androidx.media3.exoplayer.source.LoadEventInfo;
import androidx.media3.exoplayer.source.MediaLoadData;
import androidx.media3.test.utils.ThreadTestUtil;
import com.google.common.base.Supplier;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/**
* Helper methods to block the calling thread until the provided {@link ExoPlayer} instance reaches
* a particular state.
*
* <p>This class has two usage modes:
*
* <ul>
* <li>Fluent method chaining, e.g. {@code
* run(player).ignoringNonFatalErrors().untilState(STATE_ENDED)}.
* <li>Single method call, e.g. {@code runUntilPlaybackState(player, STATE_ENDED)}.
* </ul>
*
* <p>New usages should prefer the fluent method chaining, and new functionality will only be added
* to this form. The older single methods will be kept for backwards compatibility.
*/
@UnstableApi
public class TestPlayerRunHelper {
public final class TestPlayerRunHelper {
private TestPlayerRunHelper() {}
/**
* Runs tasks of the main {@link Looper} until {@link Player#getPlaybackState()} matches the
* expected state or a playback error occurs.
* Intermediate type that allows callers to run the main {@link Looper} until certain conditions
* are met.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}.
* <p>If an error occurs while a {@code untilXXX(...)} method is waiting for the condition to
* become true, most methods will throw that error (exceptions to this are documented on specific
* methods below). Use {@link #ignoringNonFatalErrors()} to ignore non-fatal errors and only fail
* on {@linkplain Player.Listener#getPlayerError() fatal playback errors}.
*
* <p>Instances of this class should only be used for a single {@code untilXXX()} invocation and
* not be re-used.
*/
public static class PlayerRunResult {
private final Player player;
private final boolean throwNonFatalErrors;
protected final boolean playBeforeWaiting;
protected boolean hasBeenUsed;
/**
* Constructs a new instance.
*
* @param player The player to interact with.
* @param playBeforeWaiting Whether to call {@link Player#play()} before waiting for the chosen
* condition.
* @param throwNonFatalErrors Whether to throw non-fatal errors passed to {@link
* AnalyticsListener}.
*/
// This constructor is deliberately private to prevent subclassing outside TestPlayerRunHelper.
private PlayerRunResult(Player player, boolean playBeforeWaiting, boolean throwNonFatalErrors) {
verifyMainTestThread(player);
if (player instanceof ExoPlayer) {
verifyPlaybackThreadIsAlive((ExoPlayer) player);
}
this.player = player;
this.playBeforeWaiting = playBeforeWaiting;
this.throwNonFatalErrors = throwNonFatalErrors;
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#getPlaybackState()} matches the
* expected state or an error occurs.
*
* @throws TimeoutException If the {@linkplain RobolectricUtil#DEFAULT_TIMEOUT_MS default
* timeout} is exceeded.
*/
public final void untilState(@Player.State int expectedState) throws Exception {
runUntil(() -> player.getPlaybackState() == expectedState);
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#getPlayWhenReady()} matches the
* expected value or an error occurs.
*
* @throws TimeoutException If the {@linkplain RobolectricUtil#DEFAULT_TIMEOUT_MS default
* timeout} is exceeded.
*/
public final void untilPlayWhenReadyIs(boolean expectedPlayWhenReady) throws Exception {
runUntil(() -> player.getPlayWhenReady() == expectedPlayWhenReady);
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#isLoading()} matches the expected
* value or an error occurs.
*
* @throws TimeoutException If the {@linkplain RobolectricUtil#DEFAULT_TIMEOUT_MS default
* timeout} is exceeded.
*/
public final void untilLoadingIs(boolean expectedIsLoading) throws Exception {
runUntil(() -> player.isLoading() == expectedIsLoading);
}
/**
* Runs tasks of the main {@link Looper} until a timeline change or an error occurs.
*
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public final Timeline untilTimelineChanges() throws Exception {
AtomicReference<@NullableType Timeline> receivedTimeline = new AtomicReference<>();
Player.Listener listener =
new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
receivedTimeline.set(timeline);
}
};
player.addListener(listener);
try {
runUntil(() -> receivedTimeline.get() != null);
return checkNotNull(receivedTimeline.get());
} finally {
player.removeListener(listener);
}
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#getCurrentTimeline()} matches the
* expected timeline or an error occurs.
*
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public final void untilTimelineChangesTo(Timeline expectedTimeline) throws Exception {
runUntil(() -> expectedTimeline.equals(player.getCurrentTimeline()));
}
/**
* Runs tasks of the main {@link Looper} until {@link
* Player.Listener#onPositionDiscontinuity(Player.PositionInfo, Player.PositionInfo, int)} is
* called with the specified {@link Player.DiscontinuityReason} or an error occurs.
*
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public final void untilPositionDiscontinuityWithReason(
@Player.DiscontinuityReason int expectedReason) throws Exception {
AtomicBoolean receivedExpectedDiscontinuityReason = new AtomicBoolean(false);
Player.Listener listener =
new Player.Listener() {
@Override
public void onPositionDiscontinuity(
Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason) {
if (reason == expectedReason) {
receivedExpectedDiscontinuityReason.set(true);
}
}
};
player.addListener(listener);
try {
runUntil(receivedExpectedDiscontinuityReason::get);
} finally {
player.removeListener(listener);
}
}
/**
* Runs tasks of the main {@link Looper} until a player error occurs.
*
* <p>Non-fatal errors are always ignored.
*
* @return The raised {@link PlaybackException}.
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public PlaybackException untilPlayerError() throws TimeoutException {
checkState(!hasBeenUsed);
hasBeenUsed = true;
runMainLooperUntil(() -> player.getPlayerError() != null);
return checkNotNull(player.getPlayerError());
}
/**
* Runs tasks of the main {@link Looper} until {@link Player.Listener#onRenderedFirstFrame} is
* called or an error occurs.
*
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public void untilFirstFrameIsRendered() throws Exception {
AtomicBoolean receivedFirstFrameRenderedCallback = new AtomicBoolean(false);
Player.Listener listener =
new Player.Listener() {
@Override
public void onRenderedFirstFrame() {
receivedFirstFrameRenderedCallback.set(true);
}
};
player.addListener(listener);
try {
runUntil(receivedFirstFrameRenderedCallback::get);
} finally {
player.removeListener(listener);
}
}
/**
* Returns a new instance where the {@code untilXXX(...)} methods ignore non-fatal errors.
*
* <p>A fatal error is defined as an error that is passed to {@link
* Player.Listener#onPlayerError(PlaybackException)} and results in the player transitioning to
* {@link Player#STATE_IDLE}. A non-fatal error is defined as an error that is passed to any
* other callback (e.g. {@link AnalyticsListener#onLoadError}).
*/
public PlayerRunResult ignoringNonFatalErrors() {
checkState(!hasBeenUsed);
hasBeenUsed = true;
return new PlayerRunResult(player, playBeforeWaiting, /* throwNonFatalErrors= */ false);
}
/** Runs the main {@link Looper} until {@code predicate} returns true or an error occurs. */
protected final void runUntil(Supplier<Boolean> predicate) throws Exception {
checkState(!hasBeenUsed);
hasBeenUsed = true;
ErrorListener errorListener = new ErrorListener(throwNonFatalErrors);
if (player instanceof ExoPlayer) {
ExoPlayer exoplayer = (ExoPlayer) player;
exoplayer.addAnalyticsListener(errorListener);
}
player.addListener(errorListener);
if (playBeforeWaiting) {
player.play();
}
try {
runMainLooperUntil(() -> predicate.get() || errorListener.hasFatalError());
} finally {
player.removeListener(errorListener);
if (player instanceof ExoPlayer) {
((ExoPlayer) player).removeAnalyticsListener(errorListener);
}
}
errorListener.maybeThrow();
}
}
/**
* An {@link ExoPlayer} specific subclass of {@link PlayerRunResult}, giving access to conditions
* that only make sense for the {@link ExoPlayer} interface.
*/
public static final class ExoPlayerRunResult extends PlayerRunResult {
private final ExoPlayer player;
private ExoPlayerRunResult(
ExoPlayer player, boolean playBeforeWaiting, boolean throwNonFatalErrors) {
super(player, playBeforeWaiting, throwNonFatalErrors);
this.player = player;
}
@Override
public ExoPlaybackException untilPlayerError() throws TimeoutException {
return (ExoPlaybackException) super.untilPlayerError();
}
/**
* Runs tasks of the main {@link Looper} until {@link ExoPlayer#isSleepingForOffload()} matches
* the expected value, or an error occurs.
*
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public void untilSleepingForOffloadBecomes(boolean expectedSleepingForOffload)
throws Exception {
AtomicBoolean receivedExpectedValue = new AtomicBoolean(false);
ExoPlayer.AudioOffloadListener listener =
new ExoPlayer.AudioOffloadListener() {
@Override
public void onSleepingForOffloadChanged(boolean sleepingForOffload) {
if (sleepingForOffload == expectedSleepingForOffload) {
receivedExpectedValue.set(true);
}
}
};
player.addAudioOffloadListener(listener);
try {
runUntil(receivedExpectedValue::get);
} finally {
player.removeAudioOffloadListener(listener);
}
}
/**
* Runs tasks of the main {@link Looper} until playback reaches the specified position or an
* 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()/play().untilXXX(...)} method
* chains, custom {@link RobolectricUtil#runMainLooperUntil} conditions, or an explicit {@link
* ThreadTestUtil#unblockThreadsWaitingForProgressOnCurrentLooper()} on the main thread.
*
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public void untilPosition(int mediaItemIndex, long positionMs) throws Exception {
checkState(!hasBeenUsed);
hasBeenUsed = true;
Looper applicationLooper = Util.getCurrentOrMainLooper();
AtomicBoolean messageHandled = new AtomicBoolean(false);
player
.createMessage(
(messageType, payload) -> {
// 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(() -> messageHandled.set(true));
try {
player.getClock().onThreadBlocked();
blockPlaybackThreadCondition.block();
} catch (InterruptedException e) {
// Ignore.
}
})
.setPosition(mediaItemIndex, positionMs)
.send();
player.play();
runMainLooperUntil(() -> messageHandled.get() || player.getPlayerError() != null);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
}
}
/**
* Runs tasks of the main {@link Looper} until playback reaches the specified media item or a
* playback error occurs.
*
* <p>The playback thread is automatically blocked from making further progress after reaching
* the media item and will only be unblocked by other {@code run()/play().untilXXX(...)} method
* chains, custom {@link RobolectricUtil#runMainLooperUntil} conditions, or an explicit {@link
* ThreadTestUtil#unblockThreadsWaitingForProgressOnCurrentLooper()} on the main thread.
*
* @param mediaItemIndex The index of the media item.
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public void untilStartOfMediaItem(int mediaItemIndex) throws Exception {
untilPosition(mediaItemIndex, /* positionMs= */ 0);
}
/**
* Runs tasks of the main {@link Looper} until the player completely handled all previously
* issued commands on the internal playback thread.
*
* <p>Both fatal and non-fatal errors are always ignored.
*
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public void untilPendingCommandsAreFullyHandled() throws Exception {
checkState(!hasBeenUsed);
hasBeenUsed = true;
// Send message to player that will arrive after all other pending commands. Thus, the message
// execution on the app thread will also happen after all other pending command
// acknowledgements have arrived back on the app thread.
AtomicBoolean receivedMessageCallback = new AtomicBoolean(false);
player
.createMessage((type, data) -> receivedMessageCallback.set(true))
.setLooper(Util.getCurrentOrMainLooper())
.send();
runMainLooperUntil(receivedMessageCallback::get);
}
@Override
public ExoPlayerRunResult ignoringNonFatalErrors() {
checkState(!hasBeenUsed);
hasBeenUsed = true;
return new ExoPlayerRunResult(player, playBeforeWaiting, /* throwNonFatalErrors= */ false);
}
}
/**
* Entry point for a fluent "wait for condition X" assertion.
*
* <p>Callers can use the returned {@link PlayerRunResult} to run the main {@link Looper} until
* certain conditions are met.
*/
public static PlayerRunResult run(Player player) {
return new PlayerRunResult(
player, /* playBeforeWaiting= */ false, /* throwNonFatalErrors= */ true);
}
/**
* Entry point for a fluent "wait for condition X" assertion.
*
* <p>Callers can use the returned {@link ExoPlayerRunResult} to run the main {@link Looper} until
* certain conditions are met.
*/
public static ExoPlayerRunResult run(ExoPlayer player) {
return new ExoPlayerRunResult(
player, /* playBeforeWaiting= */ false, /* throwNonFatalErrors= */ true);
}
/**
* Entry point for a fluent "start playback and wait for condition X" assertion.
*
* <p>Callers can use the returned {@link PlayerRunResult} to run the main {@link Looper} until
* certain conditions are met.
*
* <p>This is the same as {@link #run(Player)} but ensures {@link Player#play()} is called before
* waiting in subsequent {@code untilXXX(...)} methods.
*/
public static PlayerRunResult play(Player player) {
return new PlayerRunResult(
player, /* playBeforeWaiting= */ true, /* throwNonFatalErrors= */ true);
}
/**
* Entry point for a fluent "start playback and wait for condition X" assertion.
*
* <p>Callers can use the returned {@link ExoPlayerRunResult} to run the main {@link Looper} until
* certain conditions are met.
*
* <p>This is the same as {@link #run(ExoPlayer)} but ensures {@link ExoPlayer#play()} is called
* before waiting in subsequent {@code untilXXX(...)} methods.
*/
public static ExoPlayerRunResult play(ExoPlayer player) {
return new ExoPlayerRunResult(
player, /* playBeforeWaiting= */ true, /* throwNonFatalErrors= */ true);
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#getPlaybackState()} matches the
* expected state or an error occurs.
*
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(Player)} and {@link PlayerRunResult#untilState(int)}.
*
* @param player The {@link Player}.
* @param expectedState The expected {@link Player.State}.
@ -56,22 +484,23 @@ public class TestPlayerRunHelper {
*/
public static void runUntilPlaybackState(Player player, @Player.State int expectedState)
throws TimeoutException {
verifyMainTestThread(player);
if (player instanceof ExoPlayer) {
verifyPlaybackThreadIsAlive((ExoPlayer) player);
}
runMainLooperUntil(
() -> player.getPlaybackState() == expectedState || player.getPlayerError() != null);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
run(player).untilState(expectedState);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#getPlayWhenReady()} matches the
* expected value or a playback error occurs.
* expected value or an error occurs.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(Player)} and {@link
* PlayerRunResult#untilPlayWhenReadyIs(boolean)}.
*
* @param player The {@link Player}.
* @param expectedPlayWhenReady The expected value for {@link Player#getPlayWhenReady()}.
@ -80,23 +509,23 @@ public class TestPlayerRunHelper {
*/
public static void runUntilPlayWhenReady(Player player, boolean expectedPlayWhenReady)
throws TimeoutException {
verifyMainTestThread(player);
if (player instanceof ExoPlayer) {
verifyPlaybackThreadIsAlive((ExoPlayer) player);
}
runMainLooperUntil(
() ->
player.getPlayWhenReady() == expectedPlayWhenReady || player.getPlayerError() != null);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
run(player).untilPlayWhenReadyIs(expectedPlayWhenReady);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#isLoading()} matches the expected
* value or a playback error occurs.
* value or an error occurs.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(Player)} and {@link
* PlayerRunResult#untilLoadingIs(boolean)}.
*
* @param player The {@link Player}.
* @param expectedIsLoading The expected value for {@link Player#isLoading()}.
@ -105,22 +534,23 @@ public class TestPlayerRunHelper {
*/
public static void runUntilIsLoading(Player player, boolean expectedIsLoading)
throws TimeoutException {
verifyMainTestThread(player);
if (player instanceof ExoPlayer) {
verifyPlaybackThreadIsAlive((ExoPlayer) player);
}
runMainLooperUntil(
() -> player.isLoading() == expectedIsLoading || player.getPlayerError() != null);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
run(player).untilLoadingIs(expectedIsLoading);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Runs tasks of the main {@link Looper} until {@link Player#getCurrentTimeline()} matches the
* expected timeline or a playback error occurs.
* expected timeline or an error occurs.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(Player)} and {@link
* PlayerRunResult#untilTimelineChangesTo(Timeline)}.
*
* @param player The {@link Player}.
* @param expectedTimeline The expected {@link Timeline}.
@ -129,23 +559,22 @@ public class TestPlayerRunHelper {
*/
public static void runUntilTimelineChanged(Player player, Timeline expectedTimeline)
throws TimeoutException {
verifyMainTestThread(player);
if (player instanceof ExoPlayer) {
verifyPlaybackThreadIsAlive((ExoPlayer) player);
}
runMainLooperUntil(
() ->
expectedTimeline.equals(player.getCurrentTimeline())
|| player.getPlayerError() != null);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
run(player).untilTimelineChangesTo(expectedTimeline);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Runs tasks of the main {@link Looper} until a timeline change or a playback error occurs.
* Runs tasks of the main {@link Looper} until a timeline change or an error occurs.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(Player)} and {@link
* PlayerRunResult#untilTimelineChanges()}.
*
* @param player The {@link Player}.
* @return The new {@link Timeline}.
@ -153,30 +582,24 @@ public class TestPlayerRunHelper {
* exceeded.
*/
public static Timeline runUntilTimelineChanged(Player player) throws TimeoutException {
verifyMainTestThread(player);
AtomicReference<@NullableType Timeline> receivedTimeline = new AtomicReference<>();
Player.Listener listener =
new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
receivedTimeline.set(timeline);
}
};
player.addListener(listener);
runMainLooperUntil(() -> receivedTimeline.get() != null || player.getPlayerError() != null);
player.removeListener(listener);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
return run(player).untilTimelineChanges();
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
return checkNotNull(receivedTimeline.get());
}
/**
* Runs tasks of the main {@link Looper} until {@link
* Player.Listener#onPositionDiscontinuity(Player.PositionInfo, Player.PositionInfo, int)} is
* called with the specified {@link Player.DiscontinuityReason} or a playback error occurs.
* called with the specified {@link Player.DiscontinuityReason} or an error occurs.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(Player)} and {@link
* PlayerRunResult#untilPositionDiscontinuityWithReason(int)}.
*
* @param player The {@link Player}.
* @param expectedReason The expected {@link Player.DiscontinuityReason}.
@ -185,51 +608,40 @@ public class TestPlayerRunHelper {
*/
public static void runUntilPositionDiscontinuity(
Player player, @Player.DiscontinuityReason int expectedReason) throws TimeoutException {
verifyMainTestThread(player);
if (player instanceof ExoPlayer) {
verifyPlaybackThreadIsAlive((ExoPlayer) player);
}
AtomicBoolean receivedCallback = new AtomicBoolean(false);
Player.Listener listener =
new Player.Listener() {
@Override
public void onPositionDiscontinuity(
Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason) {
if (reason == expectedReason) {
receivedCallback.set(true);
}
}
};
player.addListener(listener);
runMainLooperUntil(() -> receivedCallback.get() || player.getPlayerError() != null);
player.removeListener(listener);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
run(player).untilPositionDiscontinuityWithReason(expectedReason);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Runs tasks of the main {@link Looper} until a player error occurs.
*
* <p>Non-fatal errors are ignored.
*
* <p>New usages should prefer {@link #run(ExoPlayer)} and {@link
* ExoPlayerRunResult#untilPlayerError()}.
*
* @param player The {@link Player}.
* @return The raised {@link ExoPlaybackException}.
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public static ExoPlaybackException runUntilError(ExoPlayer player) throws TimeoutException {
verifyMainTestThread(player);
verifyPlaybackThreadIsAlive(player);
runMainLooperUntil(() -> player.getPlayerError() != null);
return checkNotNull(player.getPlayerError());
return run(player).untilPlayerError();
}
/**
* Runs tasks of the main {@link Looper} until {@link
* ExoPlayer.AudioOffloadListener#onSleepingForOffloadChanged(boolean)} is called or a playback
* error occurs.
* Runs tasks of the main {@link Looper} until {@link ExoPlayer#isSleepingForOffload()} matches
* the expected value, or an error occurs.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(ExoPlayer)} and {@link
* ExoPlayerRunResult#untilSleepingForOffloadBecomes(boolean)}.
*
* @param player The {@link Player}.
* @param expectedSleepForOffload The expected sleep of offload state.
@ -238,66 +650,51 @@ public class TestPlayerRunHelper {
*/
public static void runUntilSleepingForOffload(ExoPlayer player, boolean expectedSleepForOffload)
throws TimeoutException {
verifyMainTestThread(player);
verifyPlaybackThreadIsAlive(player);
AtomicBoolean receiverCallback = new AtomicBoolean(false);
ExoPlayer.AudioOffloadListener listener =
new ExoPlayer.AudioOffloadListener() {
@Override
public void onSleepingForOffloadChanged(boolean sleepingForOffload) {
if (sleepingForOffload == expectedSleepForOffload) {
receiverCallback.set(true);
}
}
};
player.addAudioOffloadListener(listener);
runMainLooperUntil(() -> receiverCallback.get() || player.getPlayerError() != null);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
run(player).untilSleepingForOffloadBecomes(expectedSleepForOffload);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Runs tasks of the main {@link Looper} until the {@link Player.Listener#onRenderedFirstFrame}
* callback is called or a playback error occurs.
* Runs tasks of the main {@link Looper} until {@link Player.Listener#onRenderedFirstFrame} is
* called or an error occurs.
*
* <p>If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}..
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(Player)} and {@link
* PlayerRunResult#untilFirstFrameIsRendered()}.
*
* @param player The {@link Player}.
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public static void runUntilRenderedFirstFrame(ExoPlayer player) throws TimeoutException {
verifyMainTestThread(player);
verifyPlaybackThreadIsAlive(player);
AtomicBoolean receivedCallback = new AtomicBoolean(false);
Player.Listener listener =
new Player.Listener() {
@Override
public void onRenderedFirstFrame() {
receivedCallback.set(true);
}
};
player.addListener(listener);
runMainLooperUntil(() -> receivedCallback.get() || player.getPlayerError() != null);
player.removeListener(listener);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
run(player).untilFirstFrameIsRendered();
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Calls {@link Player#play()}, runs tasks of the main {@link Looper} until the {@code player}
* reaches the specified position or a playback error occurs.
* Calls {@link Player#play()} then runs tasks of the main {@link Looper} until the {@code player}
* reaches the specified position or an 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
* this position and will only be unblocked by other {@code runUntil/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}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(ExoPlayer)} and {@link
* ExoPlayerRunResult#untilPosition(int, long)}.
*
* @param player The {@link Player}.
* @param mediaItemIndex The index of the media item.
@ -307,47 +704,28 @@ public class TestPlayerRunHelper {
*/
public static void playUntilPosition(ExoPlayer player, int mediaItemIndex, long positionMs)
throws TimeoutException {
verifyMainTestThread(player);
verifyPlaybackThreadIsAlive(player);
Looper applicationLooper = Util.getCurrentOrMainLooper();
AtomicBoolean messageHandled = new AtomicBoolean(false);
player
.createMessage(
(messageType, payload) -> {
// 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(() -> messageHandled.set(true));
try {
player.getClock().onThreadBlocked();
blockPlaybackThreadCondition.block();
} catch (InterruptedException e) {
// Ignore.
}
})
.setPosition(mediaItemIndex, positionMs)
.send();
player.play();
runMainLooperUntil(() -> messageHandled.get() || player.getPlayerError() != null);
if (player.getPlayerError() != null) {
throw new IllegalStateException(player.getPlayerError());
try {
play(player).untilPosition(mediaItemIndex, positionMs);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Calls {@link Player#play()}, runs tasks of the main {@link Looper} until the {@code player}
* Calls {@link Player#play()} then runs tasks of the main {@link Looper} until the {@code player}
* reaches the specified media item or a playback error occurs.
*
* <p>The playback thread is automatically blocked from making further progress after reaching the
* media item and will only be unblocked by other {@code run/playUntil...} methods, custom {@link
* RobolectricUtil#runMainLooperUntil} conditions or an explicit {@link
* media item and will only be unblocked by other {@code runUntil/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}.
* <p>If a checked exception occurs it will be thrown wrapped in an {@link IllegalStateException}.
*
* <p>New usages should prefer {@link #run(ExoPlayer)} and {@link
* ExoPlayerRunResult#untilStartOfMediaItem(int)}.
*
* @param player The {@link Player}.
* @param mediaItemIndex The index of the media item.
@ -356,31 +734,34 @@ public class TestPlayerRunHelper {
*/
public static void playUntilStartOfMediaItem(ExoPlayer player, int mediaItemIndex)
throws TimeoutException {
playUntilPosition(player, mediaItemIndex, /* positionMs= */ 0);
try {
play(player).untilStartOfMediaItem(mediaItemIndex);
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/**
* Runs tasks of the main {@link Looper} until the player completely handled all previously issued
* commands on the internal playback thread.
*
* <p>Both fatal and non-fatal errors are ignored.
*
* @param player The {@link Player}.
* @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
* exceeded.
*/
public static void runUntilPendingCommandsAreFullyHandled(ExoPlayer player)
throws TimeoutException {
verifyMainTestThread(player);
verifyPlaybackThreadIsAlive(player);
// Send message to player that will arrive after all other pending commands. Thus, the message
// execution on the app thread will also happen after all other pending command
// acknowledgements have arrived back on the app thread.
AtomicBoolean receivedMessageCallback = new AtomicBoolean(false);
player
.createMessage((type, data) -> receivedMessageCallback.set(true))
.setLooper(Util.getCurrentOrMainLooper())
.send();
runMainLooperUntil(receivedMessageCallback::get);
try {
run(player).untilPendingCommandsAreFullyHandled();
} catch (RuntimeException | TimeoutException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static void verifyMainTestThread(Player player) {
@ -395,4 +776,92 @@ public class TestPlayerRunHelper {
player.getPlaybackLooper().getThread().isAlive(),
"Playback thread is not alive, has the player been released?");
}
/**
* A {@link Player.Listener} and {@link AnalyticsListener} that records errors.
*
* <p>All methods must be called on {@link Player#getApplicationLooper()}.
*/
private static final class ErrorListener implements AnalyticsListener, Player.Listener {
@Nullable private final List<Exception> nonFatalErrors;
private @MonotonicNonNull Exception fatalError;
public ErrorListener(boolean throwNonFatalErrors) {
if (throwNonFatalErrors) {
nonFatalErrors = new ArrayList<>();
} else {
nonFatalErrors = null;
}
}
public boolean hasFatalError() {
return fatalError != null;
}
public void maybeThrow() throws Exception {
if (fatalError != null) {
throw fatalError;
}
if (nonFatalErrors != null && !nonFatalErrors.isEmpty()) {
IllegalStateException ise =
new IllegalStateException(
"Non-fatal errors detected. Attach an EventLogger and redirect logcat with"
+ " ShadowLog.stream to see full details.");
for (Exception nonFatalError : nonFatalErrors) {
ise.addSuppressed(nonFatalError);
}
throw ise;
}
}
// Player.Listener impl
@Override
public void onPlayerError(PlaybackException error) {
fatalError = error;
}
// AnalyticsListener impl
@Override
public void onLoadError(
EventTime eventTime,
LoadEventInfo loadEventInfo,
MediaLoadData mediaLoadData,
IOException error,
boolean wasCanceled) {
if (nonFatalErrors != null) {
nonFatalErrors.add(error);
}
}
@Override
public void onAudioSinkError(EventTime eventTime, Exception audioSinkError) {
if (nonFatalErrors != null) {
nonFatalErrors.add(audioSinkError);
}
}
@Override
public void onAudioCodecError(EventTime eventTime, Exception audioCodecError) {
if (nonFatalErrors != null) {
nonFatalErrors.add(audioCodecError);
}
}
@Override
public void onVideoCodecError(EventTime eventTime, Exception videoCodecError) {
if (nonFatalErrors != null) {
nonFatalErrors.add(videoCodecError);
}
}
@Override
public void onDrmSessionManagerError(EventTime eventTime, Exception error) {
if (nonFatalErrors != null) {
nonFatalErrors.add(error);
}
}
}
}