TestPlayerRunHelper.java

/*
 * Copyright (C) 2020 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.robolectric;

import static androidx.media3.common.util.Assertions.checkNotNull;
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;
import androidx.media3.common.util.NullableType;
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 final class TestPlayerRunHelper {

  private TestPlayerRunHelper() {}

  /**
   * Intermediate type that allows callers to run the main {@link Looper} until certain conditions
   * are met.
   *
   * <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#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}.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void runUntilPlaybackState(Player player, @Player.State int expectedState)
      throws TimeoutException {
    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 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#untilPlayWhenReadyIs(boolean)}.
   *
   * @param player The {@link Player}.
   * @param expectedPlayWhenReady The expected value for {@link Player#getPlayWhenReady()}.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void runUntilPlayWhenReady(Player player, boolean expectedPlayWhenReady)
      throws TimeoutException {
    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 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#untilLoadingIs(boolean)}.
   *
   * @param player The {@link Player}.
   * @param expectedIsLoading The expected value for {@link Player#isLoading()}.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void runUntilIsLoading(Player player, boolean expectedIsLoading)
      throws TimeoutException {
    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 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#untilTimelineChangesTo(Timeline)}.
   *
   * @param player The {@link Player}.
   * @param expectedTimeline The expected {@link Timeline}.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void runUntilTimelineChanged(Player player, Timeline expectedTimeline)
      throws TimeoutException {
    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 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#untilTimelineChanges()}.
   *
   * @param player The {@link Player}.
   * @return The new {@link Timeline}.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static Timeline runUntilTimelineChanged(Player player) throws TimeoutException {
    try {
      return run(player).untilTimelineChanges();
    } catch (RuntimeException | TimeoutException e) {
      throw e;
    } catch (Exception e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * 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.
   *
   * <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}.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void runUntilPositionDiscontinuity(
      Player player, @Player.DiscontinuityReason int expectedReason) throws TimeoutException {
    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 {
    return run(player).untilPlayerError();
  }

  /**
   * Runs tasks of the main {@link Looper} until {@link ExoPlayer#isSleepingForOffload()} matches
   * the expected value, 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(ExoPlayer)} and {@link
   * ExoPlayerRunResult#untilSleepingForOffloadBecomes(boolean)}.
   *
   * @param player The {@link Player}.
   * @param expectedSleepForOffload The expected sleep of offload state.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void runUntilSleepingForOffload(ExoPlayer player, boolean expectedSleepForOffload)
      throws TimeoutException {
    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 {@link Player.Listener#onRenderedFirstFrame} is
   * called 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#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 {
    try {
      run(player).untilFirstFrameIsRendered();
    } catch (RuntimeException | TimeoutException e) {
      throw e;
    } catch (Exception e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * 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 runUntil/playUntil...} methods, custom
   * {@link RobolectricUtil#runMainLooperUntil} conditions or an explicit {@link
   * ThreadTestUtil#unblockThreadsWaitingForProgressOnCurrentLooper()} on the main thread.
   *
   * <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.
   * @param positionMs The position within the media item, in milliseconds.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void playUntilPosition(ExoPlayer player, int mediaItemIndex, long positionMs)
      throws TimeoutException {
    try {
      play(player).untilPosition(mediaItemIndex, positionMs);
    } catch (RuntimeException | TimeoutException e) {
      throw e;
    } catch (Exception e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * 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 runUntil/playUntil...} methods, custom
   * {@link RobolectricUtil#runMainLooperUntil} conditions or an explicit {@link
   * ThreadTestUtil#unblockThreadsWaitingForProgressOnCurrentLooper()} on the main thread.
   *
   * <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.
   * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is
   *     exceeded.
   */
  public static void playUntilStartOfMediaItem(ExoPlayer player, int mediaItemIndex)
      throws TimeoutException {
    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 {
    try {
      run(player).untilPendingCommandsAreFullyHandled();
    } catch (RuntimeException | TimeoutException e) {
      throw e;
    } catch (Exception e) {
      throw new IllegalStateException(e);
    }
  }

  private static void verifyMainTestThread(Player player) {
    if (Looper.myLooper() != Looper.getMainLooper()
        || player.getApplicationLooper() != Looper.getMainLooper()) {
      throw new IllegalStateException();
    }
  }

  private static void verifyPlaybackThreadIsAlive(ExoPlayer player) {
    checkState(
        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);
      }
    }
  }
}