/*
 * Copyright (C) 2017 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.exoplayer.offline;

import static androidx.media3.test.utils.robolectric.RobolectricUtil.createRobolectricConditionVariable;
import static com.google.common.truth.Truth.assertThat;
import static java.util.Arrays.asList;

import android.net.Uri;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.StreamKey;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.exoplayer.scheduler.Requirements;
import androidx.media3.test.utils.DownloadBuilder;
import androidx.media3.test.utils.DummyMainThread;
import androidx.media3.test.utils.DummyMainThread.TestRunnable;
import androidx.media3.test.utils.TestUtil;
import androidx.media3.test.utils.robolectric.TestDownloadManagerListener;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.shadows.ShadowLog;

/** Tests {@link DownloadManager}. */
@RunWith(AndroidJUnit4.class)
public class DownloadManagerTest {

  /** Timeout to use when blocking on conditions that we expect to become unblocked. */
  private static final int TIMEOUT_MS = 10_000;
  /** An application provided stop reason. */
  private static final int APP_STOP_REASON = 1;
  /** The minimum number of times a download must be retried before failing. */
  private static final int MIN_RETRY_COUNT = 3;
  /** Test value for the current time. */
  private static final long NOW_MS = 1234;

  private static final String ID1 = "id1";
  private static final String ID2 = "id2";
  private static final String ID3 = "id3";

  @GuardedBy("downloaders")
  private final List<FakeDownloader> downloaders = new ArrayList<>();

  private DownloadManager downloadManager;
  private TestDownloadManagerListener downloadManagerListener;
  private DummyMainThread testThread;

  @Before
  public void setUp() throws Exception {
    ShadowLog.stream = System.out;
    testThread = new DummyMainThread();
    setupDownloadManager(/* maxParallelDownloads= */ 100);
  }

  @After
  public void tearDown() throws Exception {
    releaseDownloadManager();
    testThread.release();
  }

  @Test
  public void downloadRequest_downloads() throws Throwable {
    postDownloadRequest(ID1);
    assertDownloading(ID1);

    FakeDownloader downloader = getDownloaderAt(0);
    downloader.assertId(ID1);
    downloader.assertDownloadStarted();
    downloader.finish();
    assertCompleted(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(1);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void removeRequest_cancelsAndRemovesDownload() throws Throwable {
    postDownloadRequest(ID1);

    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();
    assertDownloading(ID1);

    // The download will be canceled by the remove request.
    postRemoveRequest(ID1);
    downloader0.assertCanceled();
    assertRemoving(ID1);

    // The download will be removed.
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID1);
    downloader1.assertRemoveStarted();
    downloader1.finish();
    assertRemoved(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(0);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void download_retryUntilMinRetryCount_withoutProgress_thenFails() throws Throwable {
    postDownloadRequest(ID1);

    FakeDownloader downloader = getDownloaderAt(0);
    downloader.assertId(ID1);
    for (int i = 0; i <= MIN_RETRY_COUNT; i++) {
      downloader.assertDownloadStarted();
      downloader.fail();
    }
    assertFailed(ID1);

    downloadManagerListener.blockUntilIdle();
    assertDownloaderCount(1);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void download_retryUntilMinRetryCountMinusOne_thenSucceeds() throws Throwable {
    postDownloadRequest(ID1);

    FakeDownloader downloader = getDownloaderAt(0);
    downloader.assertId(ID1);
    for (int i = 0; i < MIN_RETRY_COUNT; i++) {
      downloader.assertDownloadStarted();
      downloader.fail();
    }
    downloader.assertDownloadStarted();
    downloader.finish();
    assertCompleted(ID1);

    downloadManagerListener.blockUntilIdle();
    assertDownloaderCount(1);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void download_retryMakesProgress_resetsRetryCount() throws Throwable {
    postDownloadRequest(ID1);

    FakeDownloader downloader = getDownloaderAt(0);
    downloader.assertId(ID1);
    for (int i = 0; i <= MIN_RETRY_COUNT; i++) {
      downloader.assertDownloadStarted();
      downloader.incrementBytesDownloaded(); // Make some progress.
      downloader.fail();
    }
    // Since previous attempts all made progress the current error count should be 1. Therefore we
    // should be able to fail (MIN_RETRY_COUNT - 1) more times and then still complete the download
    // successfully.
    for (int i = 0; i < MIN_RETRY_COUNT - 1; i++) {
      downloader.assertDownloadStarted();
      downloader.fail();
    }
    downloader.assertDownloadStarted();
    downloader.finish();
    assertCompleted(ID1);

    downloadManagerListener.blockUntilIdle();
    assertDownloaderCount(1);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void download_retryMakesProgress_resetsRetryCount_thenFails() throws Throwable {
    postDownloadRequest(ID1);

    FakeDownloader downloader = getDownloaderAt(0);
    downloader.assertId(ID1);
    for (int i = 0; i <= MIN_RETRY_COUNT; i++) {
      downloader.assertDownloadStarted();
      downloader.incrementBytesDownloaded(); // Make some progress.
      downloader.fail();
    }
    // Since previous attempts all made progress the current error count should be 1. Therefore we
    // should fail after MIN_RETRY_COUNT more attempts without making any progress.
    for (int i = 0; i < MIN_RETRY_COUNT; i++) {
      downloader.assertDownloadStarted();
      downloader.fail();
    }
    assertFailed(ID1);

    downloadManagerListener.blockUntilIdle();
    assertDownloaderCount(1);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void download_WhenRemovalInProgress_doesNotCancelRemoval() throws Throwable {
    postDownloadRequest(ID1);
    postRemoveRequest(ID1);
    assertRemoving(ID1);

    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID1);
    downloader1.assertRemoveStarted();

    postDownloadRequest(ID1);
    // The removal should still complete.
    downloader1.finish();

    // The download should then start and complete.
    FakeDownloader downloader2 = getDownloaderAt(2);
    downloader2.assertId(ID1);
    downloader2.assertDownloadStarted();
    downloader2.finish();
    assertCompleted(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(3);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void remove_WhenRemovalInProgress_doesNothing() throws Throwable {
    postDownloadRequest(ID1);
    postRemoveRequest(ID1);
    assertRemoving(ID1);

    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID1);
    downloader1.assertRemoveStarted();

    postRemoveRequest(ID1);
    // The existing removal should still complete.
    downloader1.finish();
    assertRemoved(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(0);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void removeAllDownloads_removesAllDownloads() throws Throwable {
    // Finish one download.
    postDownloadRequest(ID1);
    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();
    downloader0.finish();
    assertCompleted(ID1);

    // Start a second download.
    postDownloadRequest(ID2);
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID2);
    downloader1.assertDownloadStarted();

    postRemoveAllRequest();
    // Both downloads should be removed.
    FakeDownloader downloader2 = getDownloaderAt(2);
    FakeDownloader downloader3 = getDownloaderAt(3);
    downloader2.assertId(ID1);
    downloader3.assertId(ID2);
    downloader2.assertRemoveStarted();
    downloader3.assertRemoveStarted();
    downloader2.finish();
    downloader3.finish();
    assertRemoved(ID1);
    assertRemoved(ID2);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(4);
    assertDownloadIndexSize(0);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void downloads_withSameIdsAndDifferentStreamKeys_areMerged() throws Throwable {
    StreamKey streamKey1 = new StreamKey(/* groupIndex= */ 0, /* trackIndex= */ 0);
    postDownloadRequest(ID1, streamKey1);
    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();

    StreamKey streamKey2 = new StreamKey(/* groupIndex= */ 1, /* trackIndex= */ 1);
    postDownloadRequest(ID1, streamKey2);
    // The request for streamKey2 will cause the downloader for streamKey1 to be canceled and
    // replaced with a new downloader for both keys.
    downloader0.assertCanceled();
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID1);
    downloader1.assertStreamKeys(streamKey1, streamKey2);
    downloader1.assertDownloadStarted();
    downloader1.finish();
    assertCompleted(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void downloads_withDifferentIds_executeInParallel() throws Throwable {
    postDownloadRequest(ID1);
    postDownloadRequest(ID2);

    FakeDownloader downloader0 = getDownloaderAt(0);
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader0.assertId(ID1);
    downloader1.assertId(ID2);
    downloader0.assertDownloadStarted();
    downloader1.assertDownloadStarted();
    downloader0.finish();
    downloader1.finish();
    assertCompleted(ID1);
    assertCompleted(ID2);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(2);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void downloads_withDifferentIds_maxDownloadsIsOne_executedSequentially() throws Throwable {
    setupDownloadManager(/* maxParallelDownloads= */ 1);
    postDownloadRequest(ID1);
    postDownloadRequest(ID2);
    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();

    // The second download should be queued and the first one should be able to complete.
    assertNoDownloaderAt(1);
    assertQueued(ID2);
    downloader0.finish();
    assertCompleted(ID1);

    // The second download can start once the first one has completed.
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID2);
    downloader1.assertDownloadStarted();
    downloader1.finish();
    assertCompleted(ID2);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(2);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void downloadAndRemove_withDifferentIds_maxDownloadsIsOne_executeInParallel()
      throws Throwable {
    setupDownloadManager(/* maxParallelDownloads= */ 1);

    // Complete a download so that we can remove it.
    postDownloadRequest(ID1);
    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();
    downloader0.finish();

    // Request removal of the first download, and downloading of a second download.
    postRemoveRequest(ID1);
    postDownloadRequest(ID2);

    // The removal and download should proceed in parallel.
    FakeDownloader downloader1 = getDownloaderAt(1);
    FakeDownloader downloader2 = getDownloaderAt(2);
    downloader1.assertId(ID1);
    downloader2.assertId(ID2);
    downloader1.assertRemoveStarted();
    downloader2.assertDownloadStarted();
    downloader1.finish();
    downloader2.finish();
    assertRemoved(ID1);
    assertCompleted(ID2);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(3);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void downloadAfterRemove_maxDownloadIsOne_isNotStarted() throws Throwable {
    setupDownloadManager(/* maxParallelDownloads= */ 1);
    postDownloadRequest(ID1);
    postDownloadRequest(ID2);
    postRemoveRequest(ID2);
    postDownloadRequest(ID2);

    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();

    // The second download shouldn't have been started, so the second downloader is for removal.
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID2);
    downloader1.assertRemoveStarted();
    downloader1.finish();
    // A downloader to re-download the second download should not be started.
    assertNoDownloaderAt(2);
    // The first download should be able to complete.
    downloader0.finish();
    assertCompleted(ID1);

    // Now the first download has completed, the second download should start.
    FakeDownloader downloader2 = getDownloaderAt(2);
    downloader2.assertId(ID2);
    downloader2.assertDownloadStarted();
    downloader2.finish();
    assertCompleted(ID2);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(3);
    assertDownloadIndexSize(2);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void pauseAndResume_pausesAndResumesDownload() throws Throwable {
    postDownloadRequest(ID1);
    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();

    postPauseDownloads();
    downloader0.assertCanceled();
    assertQueued(ID1);

    postResumeDownloads();
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID1);
    downloader1.assertDownloadStarted();
    downloader1.finish();
    assertCompleted(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void pause_doesNotCancelRemove() throws Throwable {
    postDownloadRequest(ID1);
    postRemoveRequest(ID1);
    FakeDownloader downloader = getDownloaderAt(1);
    downloader.assertId(ID1);
    downloader.assertRemoveStarted();

    postPauseDownloads();
    downloader.finish();
    assertRemoved(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(0);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void setAndClearStopReason_stopsAndRestartsDownload() throws Throwable {
    postDownloadRequest(ID1);
    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();

    postSetStopReason(ID1, APP_STOP_REASON);
    downloader0.assertCanceled();
    assertStopped(ID1);

    postSetStopReason(ID1, Download.STOP_REASON_NONE);
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID1);
    downloader1.assertDownloadStarted();
    downloader1.finish();

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(1);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void setStopReason_doesNotStopOtherDownload() throws Throwable {
    postDownloadRequest(ID1);
    postDownloadRequest(ID2);

    FakeDownloader downloader0 = getDownloaderAt(0);
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader0.assertId(ID1);
    downloader1.assertId(ID2);
    downloader0.assertDownloadStarted();
    downloader1.assertDownloadStarted();

    postSetStopReason(ID1, APP_STOP_REASON);
    downloader0.assertCanceled();
    assertStopped(ID1);

    // The second download should still complete.
    downloader1.finish();
    assertCompleted(ID2);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(2);
    assertCurrentDownloadCount(1);
  }

  @Test
  public void remove_removesStoppedDownload() throws Throwable {
    postDownloadRequest(ID1);
    FakeDownloader downloader0 = getDownloaderAt(0);
    downloader0.assertId(ID1);
    downloader0.assertDownloadStarted();

    postSetStopReason(ID1, APP_STOP_REASON);
    downloader0.assertCanceled();
    assertStopped(ID1);

    postRemoveRequest(ID1);
    FakeDownloader downloader1 = getDownloaderAt(1);
    downloader1.assertId(ID1);
    downloader1.assertRemoveStarted();
    downloader1.finish();
    assertRemoved(ID1);

    downloadManagerListener.blockUntilIdleAndThrowAnyFailure();
    assertDownloaderCount(2);
    assertDownloadIndexSize(0);
    assertCurrentDownloadCount(0);
  }

  @Test
  public void getCurrentDownloads_returnsCurrentDownloads() throws Throwable {
    setupDownloadManager(/* maxParallelDownloads= */ 1);
    postDownloadRequest(ID1);
    postDownloadRequest(ID2);
    postDownloadRequest(ID3);
    postRemoveRequest(ID3);

    assertRemoving(ID3); // Blocks until the downloads will be visible.

    List<Download> downloads = postGetCurrentDownloads();
    assertThat(downloads).hasSize(3);
    Download download0 = downloads.get(0);
    assertThat(download0.request.id).isEqualTo(ID1);
    assertThat(download0.state).isEqualTo(Download.STATE_DOWNLOADING);
    Download download1 = downloads.get(1);
    assertThat(download1.request.id).isEqualTo(ID2);
    assertThat(download1.state).isEqualTo(Download.STATE_QUEUED);
    Download download2 = downloads.get(2);
    assertThat(download2.request.id).isEqualTo(ID3);
    assertThat(download2.state).isEqualTo(Download.STATE_REMOVING);
  }

  @Test
  public void addDownload_whilstRemovingWithStopReason_addsStartedDownload() throws Throwable {
    runOnMainThread(
        () -> downloadManager.addDownload(createDownloadRequest(ID1), /* stopReason= */ 1234));

    postRemoveRequest(ID1);
    FakeDownloader downloadRemover = getDownloaderAt(0);
    downloadRemover.assertRemoveStarted();

    // Re-add the download without a stop reason.
    postDownloadRequest(ID1);

    downloadRemover.finish();

    FakeDownloader downloader = getDownloaderAt(1);
    downloader.finish();
    assertCompleted(ID1);

    assertDownloadIndexSize(1);
    // We expect one downloader for the removal, and one for when the download was re-added.
    assertDownloaderCount(2);
    // The download has completed, and so is no longer current.
    assertCurrentDownloadCount(0);

    Download download = postGetDownloadIndex().getDownload(ID1);
    assertThat(download.state).isEqualTo(Download.STATE_COMPLETED);
    assertThat(download.stopReason).isEqualTo(0);
  }

  /** Test for https://github.com/google/ExoPlayer/issues/8419 */
  @Test
  public void addDownloadWithStopReason_whilstRemoving_addsStoppedDownload() throws Throwable {
    postDownloadRequest(ID1);
    getDownloaderAt(0).finish();
    assertCompleted(ID1);

    postRemoveRequest(ID1);
    FakeDownloader downloadRemover = getDownloaderAt(1);
    downloadRemover.assertRemoveStarted();

    // Re-add the download with a stop reason.
    runOnMainThread(
        () -> downloadManager.addDownload(createDownloadRequest(ID1), /* stopReason= */ 1234));

    downloadRemover.finish();
    downloadManagerListener.blockUntilIdle();

    assertDownloadIndexSize(1);
    // We expect one downloader for the initial download, and one for the removal. A downloader
    // should not be created when the download is re-added, since a stop reason is specified.
    assertDownloaderCount(2);
    // The download isn't completed, and is therefore still current.
    assertCurrentDownloadCount(1);

    List<Download> downloads = postGetCurrentDownloads();
    Download download = downloads.get(0);
    assertThat(download.request.id).isEqualTo(ID1);
    assertThat(download.state).isEqualTo(Download.STATE_STOPPED);
    assertThat(download.stopReason).isEqualTo(1234);
  }

  @Test
  public void mergeRequest_removing_becomesRestarting() {
    DownloadRequest downloadRequest = createDownloadRequest(ID1);
    DownloadBuilder downloadBuilder =
        new DownloadBuilder(downloadRequest).setState(Download.STATE_REMOVING);
    Download download = downloadBuilder.build();

    Download mergedDownload =
        DownloadManager.mergeRequest(download, downloadRequest, download.stopReason, NOW_MS);

    Download expectedDownload =
        downloadBuilder.setStartTimeMs(NOW_MS).setState(Download.STATE_RESTARTING).build();
    assertEqualIgnoringUpdateTime(mergedDownload, expectedDownload);
  }

  @Test
  public void mergeRequest_failed_becomesQueued() {
    DownloadRequest downloadRequest = createDownloadRequest(ID1);
    DownloadBuilder downloadBuilder =
        new DownloadBuilder(downloadRequest)
            .setState(Download.STATE_FAILED)
            .setFailureReason(Download.FAILURE_REASON_UNKNOWN);
    Download download = downloadBuilder.build();

    Download mergedDownload =
        DownloadManager.mergeRequest(download, downloadRequest, download.stopReason, NOW_MS);

    Download expectedDownload =
        downloadBuilder
            .setStartTimeMs(NOW_MS)
            .setState(Download.STATE_QUEUED)
            .setFailureReason(Download.FAILURE_REASON_NONE)
            .build();
    assertEqualIgnoringUpdateTime(mergedDownload, expectedDownload);
  }

  @Test
  public void mergeRequest_stopped_staysStopped() {
    DownloadRequest downloadRequest = createDownloadRequest(ID1);
    DownloadBuilder downloadBuilder =
        new DownloadBuilder(downloadRequest)
            .setState(Download.STATE_STOPPED)
            .setStopReason(/* stopReason= */ 1);
    Download download = downloadBuilder.build();

    Download mergedDownload =
        DownloadManager.mergeRequest(download, downloadRequest, download.stopReason, NOW_MS);

    assertEqualIgnoringUpdateTime(mergedDownload, download);
  }

  @Test
  public void mergeRequest_completedWithStopReason_becomesStopped() {
    DownloadRequest downloadRequest = createDownloadRequest(ID1);
    DownloadBuilder downloadBuilder =
        new DownloadBuilder(downloadRequest)
            .setState(Download.STATE_COMPLETED)
            .setStopReason(/* stopReason= */ 1);
    Download download = downloadBuilder.build();

    Download mergedDownload =
        DownloadManager.mergeRequest(download, downloadRequest, download.stopReason, NOW_MS);

    Download expectedDownload =
        downloadBuilder.setStartTimeMs(NOW_MS).setState(Download.STATE_STOPPED).build();
    assertEqualIgnoringUpdateTime(mergedDownload, expectedDownload);
  }

  private void setupDownloadManager(int maxParallelDownloads) throws Exception {
    if (downloadManager != null) {
      releaseDownloadManager();
    }
    try {
      runOnMainThread(
          () -> {
            downloadManager =
                new DownloadManager(
                    ApplicationProvider.getApplicationContext(),
                    new DefaultDownloadIndex(TestUtil.getInMemoryDatabaseProvider()),
                    new FakeDownloaderFactory());
            downloadManager.setMaxParallelDownloads(maxParallelDownloads);
            downloadManager.setMinRetryCount(MIN_RETRY_COUNT);
            downloadManager.setRequirements(new Requirements(0));
            downloadManager.resumeDownloads();
            downloadManagerListener = new TestDownloadManagerListener(downloadManager);
          });
      downloadManagerListener.blockUntilInitialized();
    } catch (Throwable throwable) {
      throw new Exception(throwable);
    }
  }

  private void releaseDownloadManager() throws Exception {
    try {
      runOnMainThread(() -> downloadManager.release());
    } catch (Throwable throwable) {
      throw new Exception(throwable);
    }
  }

  private void postRemoveRequest(String id) {
    runOnMainThread(() -> downloadManager.removeDownload(id));
  }

  private void postRemoveAllRequest() {
    runOnMainThread(() -> downloadManager.removeAllDownloads());
  }

  private void postPauseDownloads() {
    runOnMainThread(() -> downloadManager.pauseDownloads());
  }

  private void postResumeDownloads() {
    runOnMainThread(() -> downloadManager.resumeDownloads());
  }

  private void postSetStopReason(String id, int reason) {
    runOnMainThread(() -> downloadManager.setStopReason(id, reason));
  }

  private void postDownloadRequest(String id, StreamKey... keys) {
    runOnMainThread(() -> downloadManager.addDownload(createDownloadRequest(id, keys)));
  }

  private List<Download> postGetCurrentDownloads() {
    AtomicReference<List<Download>> currentDownloadsReference = new AtomicReference<>();
    runOnMainThread(() -> currentDownloadsReference.set(downloadManager.getCurrentDownloads()));
    return currentDownloadsReference.get();
  }

  private DownloadIndex postGetDownloadIndex() {
    AtomicReference<DownloadIndex> downloadIndexReference = new AtomicReference<>();
    runOnMainThread(() -> downloadIndexReference.set(downloadManager.getDownloadIndex()));
    return downloadIndexReference.get();
  }

  private void runOnMainThread(TestRunnable r) {
    testThread.runTestOnMainThread(r);
  }

  private FakeDownloader getDownloaderAt(int index) throws InterruptedException {
    return Assertions.checkNotNull(getDownloaderInternal(index, TIMEOUT_MS));
  }

  private void assertNoDownloaderAt(int index) throws InterruptedException {
    // We use a timeout shorter than TIMEOUT_MS because timing out is expected in this case.
    assertThat(getDownloaderInternal(index, /* timeoutMs= */ 1_000)).isNull();
  }

  private void assertDownloading(String id) {
    downloadManagerListener.assertState(id, Download.STATE_DOWNLOADING);
  }

  private void assertCompleted(String id) {
    downloadManagerListener.assertState(id, Download.STATE_COMPLETED);
  }

  private void assertRemoving(String id) {
    downloadManagerListener.assertState(id, Download.STATE_REMOVING);
  }

  private void assertFailed(String id) {
    downloadManagerListener.assertState(id, Download.STATE_FAILED);
  }

  private void assertQueued(String id) {
    downloadManagerListener.assertState(id, Download.STATE_QUEUED);
  }

  private void assertStopped(String id) {
    downloadManagerListener.assertState(id, Download.STATE_STOPPED);
  }

  private void assertRemoved(String id) {
    downloadManagerListener.assertRemoved(id);
  }

  private void assertDownloaderCount(int expectedCount) {
    synchronized (downloaders) {
      assertThat(downloaders).hasSize(expectedCount);
    }
  }

  private void assertCurrentDownloadCount(int expectedCount) {
    assertThat(postGetCurrentDownloads()).hasSize(expectedCount);
  }

  private void assertDownloadIndexSize(int expectedSize) throws IOException {
    assertThat(postGetDownloadIndex().getDownloads().getCount()).isEqualTo(expectedSize);
  }

  private static void assertEqualIgnoringUpdateTime(Download download, Download that) {
    assertThat(download.request).isEqualTo(that.request);
    assertThat(download.state).isEqualTo(that.state);
    assertThat(download.startTimeMs).isEqualTo(that.startTimeMs);
    assertThat(download.contentLength).isEqualTo(that.contentLength);
    assertThat(download.failureReason).isEqualTo(that.failureReason);
    assertThat(download.stopReason).isEqualTo(that.stopReason);
    assertThat(download.getPercentDownloaded()).isEqualTo(that.getPercentDownloaded());
    assertThat(download.getBytesDownloaded()).isEqualTo(that.getBytesDownloaded());
  }

  private static DownloadRequest createDownloadRequest(String id, StreamKey... keys) {
    return new DownloadRequest.Builder(id, Uri.parse("http://abc.com/ " + id))
        .setStreamKeys(asList(keys))
        .build();
  }

  // Internal methods.

  @Nullable
  private FakeDownloader getDownloaderInternal(int index, long timeoutMs)
      throws InterruptedException {
    long nowMs = System.currentTimeMillis();
    long endMs = nowMs + timeoutMs;
    synchronized (downloaders) {
      while (downloaders.size() <= index && nowMs < endMs) {
        downloaders.wait(endMs - nowMs);
        nowMs = System.currentTimeMillis();
      }
      return downloaders.size() <= index ? null : downloaders.get(index);
    }
  }

  private final class FakeDownloaderFactory implements DownloaderFactory {

    @Override
    public Downloader createDownloader(DownloadRequest request) {
      FakeDownloader fakeDownloader = new FakeDownloader(request);
      synchronized (downloaders) {
        downloaders.add(fakeDownloader);
        downloaders.notifyAll();
      }
      return fakeDownloader;
    }
  }

  private static final class FakeDownloader implements Downloader {

    private final DownloadRequest request;
    private final ConditionVariable downloadStarted;
    private final ConditionVariable removeStarted;
    private final ConditionVariable finished;
    private final ConditionVariable blocker;
    private final AtomicInteger startCount;
    private final AtomicInteger bytesDownloaded;

    private volatile boolean canceled;
    private volatile boolean enableDownloadIOException;

    private FakeDownloader(DownloadRequest request) {
      this.request = request;
      downloadStarted = createRobolectricConditionVariable();
      removeStarted = createRobolectricConditionVariable();
      finished = createRobolectricConditionVariable();
      blocker = createRobolectricConditionVariable();
      startCount = new AtomicInteger();
      bytesDownloaded = new AtomicInteger();
    }

    @Override
    public void cancel() {
      canceled = true;
      blocker.open();
    }

    @Override
    public void download(ProgressListener listener) throws IOException {
      startCount.incrementAndGet();
      downloadStarted.open();
      try {
        block();
        if (canceled) {
          return;
        }
        int bytesDownloaded = this.bytesDownloaded.get();
        if (listener != null && bytesDownloaded > 0) {
          listener.onProgress(C.LENGTH_UNSET, bytesDownloaded, C.PERCENTAGE_UNSET);
        }
        if (enableDownloadIOException) {
          enableDownloadIOException = false;
          throw new IOException();
        }
      } finally {
        finished.open();
      }
    }

    @Override
    public void remove() {
      startCount.incrementAndGet();
      removeStarted.open();
      try {
        block();
      } finally {
        finished.open();
      }
    }

    /** Finishes the {@link #download} or {@link #remove} without an error. */
    public void finish() throws InterruptedException {
      blocker.open();
      blockUntilFinished();
    }

    /** Fails {@link #download} or {@link #remove} with an error. */
    public void fail() throws InterruptedException {
      enableDownloadIOException = true;
      blocker.open();
      blockUntilFinished();
    }

    /** Increments the number of bytes that the fake downloader has downloaded. */
    public void incrementBytesDownloaded() {
      bytesDownloaded.incrementAndGet();
    }

    public void assertId(String id) {
      assertThat(request.id).isEqualTo(id);
    }

    public void assertStreamKeys(StreamKey... streamKeys) {
      assertThat(request.streamKeys).containsExactlyElementsIn(streamKeys);
    }

    public void assertDownloadStarted() throws InterruptedException {
      assertThat(downloadStarted.block(TIMEOUT_MS)).isTrue();
      downloadStarted.close();
    }

    public void assertRemoveStarted() throws InterruptedException {
      assertThat(removeStarted.block(TIMEOUT_MS)).isTrue();
      removeStarted.close();
    }

    public void assertCanceled() throws InterruptedException {
      blockUntilFinished();
      assertThat(canceled).isTrue();
    }

    // Internal methods.

    private void block() {
      try {
        blocker.block();
      } catch (InterruptedException e) {
        throw new IllegalStateException(e); // Never happens.
      } finally {
        blocker.close();
      }
    }

    private void blockUntilFinished() throws InterruptedException {
      assertThat(finished.block(TIMEOUT_MS)).isTrue();
      finished.close();
    }
  }
}
