public final class

DownloadManager

extends java.lang.Object

 java.lang.Object

↳androidx.media3.exoplayer.offline.DownloadManager

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-exoplayer', version: '1.0.0-alpha03'

  • groupId: androidx.media3
  • artifactId: media3-exoplayer
  • version: 1.0.0-alpha03

Artifact androidx.media3:media3-exoplayer:1.0.0-alpha03 it located at Google repository (https://maven.google.com/)

Overview

Manages downloads.

Normally a download manager should be accessed via a DownloadService. When a download manager is used directly instead, downloads will be initially paused and so must be resumed by calling DownloadManager.resumeDownloads().

A download manager instance must be accessed only from the thread that created it, unless that thread does not have a . In that case, it must be accessed only from the application's main thread. Registered listeners will be called on the same thread. In all cases the `Looper` of the thread from which the manager must be accessed can be queried using DownloadManager.getApplicationLooper().

Summary

Fields
public static final intDEFAULT_MAX_PARALLEL_DOWNLOADS

The default maximum number of parallel downloads.

public static final intDEFAULT_MIN_RETRY_COUNT

The default minimum number of times a download must be retried before failing.

public static final RequirementsDEFAULT_REQUIREMENTS

The default requirement is that the device has network connectivity.

Constructors
publicDownloadManager(Context context, DatabaseProvider databaseProvider, Cache cache, DataSource.Factory upstreamFactory)

Constructs a DownloadManager.

publicDownloadManager(Context context, DatabaseProvider databaseProvider, Cache cache, DataSource.Factory upstreamFactory, java.util.concurrent.Executor executor)

Constructs a DownloadManager.

publicDownloadManager(Context context, WritableDownloadIndex downloadIndex, DownloaderFactory downloaderFactory)

Constructs a DownloadManager.

Methods
public voidaddDownload(DownloadRequest request)

Adds a download defined by the given request.

public voidaddDownload(DownloadRequest request, int stopReason)

Adds a download defined by the given request and with the specified stop reason.

public voidaddListener(DownloadManager.Listener listener)

Adds a DownloadManager.Listener.

public LoopergetApplicationLooper()

Returns the associated with the application thread that's used to access the manager, and on which the manager will call its Listeners.

public java.util.List<Download>getCurrentDownloads()

Returns current downloads.

public DownloadIndexgetDownloadIndex()

Returns the used DownloadIndex.

public booleangetDownloadsPaused()

Returns whether downloads are currently paused.

public intgetMaxParallelDownloads()

Returns the maximum number of parallel downloads.

public intgetMinRetryCount()

Returns the minimum number of times that a download will be retried.

public intgetNotMetRequirements()

Returns the requirements needed for downloads to progress that are not currently met.

public RequirementsgetRequirements()

Returns the requirements needed to be met to progress.

public booleanisIdle()

Returns whether the manager is currently idle.

public booleanisInitialized()

Returns whether the manager has completed initialization.

public booleanisWaitingForRequirements()

Returns whether this manager has one or more downloads that are not progressing for the sole reason that the Requirements are not met.

public voidpauseDownloads()

Pauses downloads.

public voidrelease()

Stops the downloads and releases resources.

public voidremoveAllDownloads()

Cancels all pending downloads and removes all downloaded data.

public voidremoveDownload(java.lang.String id)

Cancels the download with the id and removes all downloaded data.

public voidremoveListener(DownloadManager.Listener listener)

Removes a DownloadManager.Listener.

public voidresumeDownloads()

Resumes downloads.

public voidsetMaxParallelDownloads(int maxParallelDownloads)

Sets the maximum number of parallel downloads.

public voidsetMinRetryCount(int minRetryCount)

Sets the minimum number of times that a download will be retried.

public voidsetRequirements(Requirements requirements)

Sets the requirements that need to be met for downloads to progress.

public voidsetStopReason(java.lang.String id, int stopReason)

Sets the stop reason for one or all downloads.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Fields

public static final int DEFAULT_MAX_PARALLEL_DOWNLOADS

The default maximum number of parallel downloads.

public static final int DEFAULT_MIN_RETRY_COUNT

The default minimum number of times a download must be retried before failing.

public static final Requirements DEFAULT_REQUIREMENTS

The default requirement is that the device has network connectivity.

Constructors

public DownloadManager(Context context, DatabaseProvider databaseProvider, Cache cache, DataSource.Factory upstreamFactory)

Deprecated: Use DownloadManager.

Constructs a DownloadManager.

Parameters:

context: Any context.
databaseProvider: Provides the SQLite database in which downloads are persisted.
cache: A cache to be used to store downloaded data. The cache should be configured with an CacheEvictor that will not evict downloaded content, for example NoOpCacheEvictor.
upstreamFactory: A DataSource.Factory for creating DataSources for downloading data.

public DownloadManager(Context context, DatabaseProvider databaseProvider, Cache cache, DataSource.Factory upstreamFactory, java.util.concurrent.Executor executor)

Constructs a DownloadManager.

Parameters:

context: Any context.
databaseProvider: Provides the SQLite database in which downloads are persisted.
cache: A cache to be used to store downloaded data. The cache should be configured with an CacheEvictor that will not evict downloaded content, for example NoOpCacheEvictor.
upstreamFactory: A DataSource.Factory for creating DataSources for downloading data.
executor: An java.util.concurrent.Executor used to download data. Passing Runnable::run will cause each download task to download data on its own thread. Passing an java.util.concurrent.Executor that uses multiple threads will speed up download tasks that can be split into smaller parts for parallel execution.

public DownloadManager(Context context, WritableDownloadIndex downloadIndex, DownloaderFactory downloaderFactory)

Constructs a DownloadManager.

Parameters:

context: Any context.
downloadIndex: The download index used to hold the download information.
downloaderFactory: A factory for creating Downloaders.

Methods

public Looper getApplicationLooper()

Returns the associated with the application thread that's used to access the manager, and on which the manager will call its Listeners.

public boolean isInitialized()

Returns whether the manager has completed initialization.

public boolean isIdle()

Returns whether the manager is currently idle. The manager is idle if all downloads are in a terminal state (i.e. completed or failed), or if no progress can be made (e.g. because the download requirements are not met).

public boolean isWaitingForRequirements()

Returns whether this manager has one or more downloads that are not progressing for the sole reason that the Requirements are not met. This is true if:

public void addListener(DownloadManager.Listener listener)

Adds a DownloadManager.Listener.

Parameters:

listener: The listener to be added.

public void removeListener(DownloadManager.Listener listener)

Removes a DownloadManager.Listener.

Parameters:

listener: The listener to be removed.

public Requirements getRequirements()

Returns the requirements needed to be met to progress.

public int getNotMetRequirements()

Returns the requirements needed for downloads to progress that are not currently met.

Returns:

The not met , or 0 if all requirements are met.

public void setRequirements(Requirements requirements)

Sets the requirements that need to be met for downloads to progress.

Parameters:

requirements: A Requirements.

public int getMaxParallelDownloads()

Returns the maximum number of parallel downloads.

public void setMaxParallelDownloads(int maxParallelDownloads)

Sets the maximum number of parallel downloads.

Parameters:

maxParallelDownloads: The maximum number of parallel downloads. Must be greater than 0.

public int getMinRetryCount()

Returns the minimum number of times that a download will be retried. A download will fail if the specified number of retries is exceeded without any progress being made.

public void setMinRetryCount(int minRetryCount)

Sets the minimum number of times that a download will be retried. A download will fail if the specified number of retries is exceeded without any progress being made.

Parameters:

minRetryCount: The minimum number of times that a download will be retried.

public DownloadIndex getDownloadIndex()

Returns the used DownloadIndex.

public java.util.List<Download> getCurrentDownloads()

Returns current downloads. Downloads that are in terminal states (i.e. completed or failed) are not included. To query all downloads including those in terminal states, use DownloadManager.getDownloadIndex() instead.

public boolean getDownloadsPaused()

Returns whether downloads are currently paused.

public void resumeDownloads()

Resumes downloads.

If the Requirements are met up to maxParallelDownloads will be started, excluding those with non-zero stopReasons.

public void pauseDownloads()

Pauses downloads. Downloads that would otherwise be making progress will transition to Download.STATE_QUEUED.

public void setStopReason(java.lang.String id, int stopReason)

Sets the stop reason for one or all downloads. To clear the stop reason, pass Download.STOP_REASON_NONE.

Parameters:

id: The content id of the download to update, or null to set the stop reason for all downloads.
stopReason: The stop reason, or Download.STOP_REASON_NONE.

public void addDownload(DownloadRequest request)

Adds a download defined by the given request.

Parameters:

request: The download request.

public void addDownload(DownloadRequest request, int stopReason)

Adds a download defined by the given request and with the specified stop reason.

Parameters:

request: The download request.
stopReason: An initial stop reason for the download, or Download.STOP_REASON_NONE if the download should be started.

public void removeDownload(java.lang.String id)

Cancels the download with the id and removes all downloaded data.

Parameters:

id: The unique content id of the download to be started.

public void removeAllDownloads()

Cancels all pending downloads and removes all downloaded data.

public void release()

Stops the downloads and releases resources. Waits until the downloads are persisted to the download index. The manager must not be accessed after this method has been called.

Source

/*
 * 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.exoplayer.offline.Download.FAILURE_REASON_NONE;
import static androidx.media3.exoplayer.offline.Download.FAILURE_REASON_UNKNOWN;
import static androidx.media3.exoplayer.offline.Download.STATE_COMPLETED;
import static androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING;
import static androidx.media3.exoplayer.offline.Download.STATE_FAILED;
import static androidx.media3.exoplayer.offline.Download.STATE_QUEUED;
import static androidx.media3.exoplayer.offline.Download.STATE_REMOVING;
import static androidx.media3.exoplayer.offline.Download.STATE_RESTARTING;
import static androidx.media3.exoplayer.offline.Download.STATE_STOPPED;
import static androidx.media3.exoplayer.offline.Download.STOP_REASON_NONE;
import static java.lang.Math.min;

import android.content.Context;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import androidx.annotation.CheckResult;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.database.DatabaseProvider;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSource.Factory;
import androidx.media3.datasource.cache.Cache;
import androidx.media3.datasource.cache.CacheDataSource;
import androidx.media3.datasource.cache.CacheEvictor;
import androidx.media3.datasource.cache.NoOpCacheEvictor;
import androidx.media3.exoplayer.scheduler.Requirements;
import androidx.media3.exoplayer.scheduler.RequirementsWatcher;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.Executor;

/**
 * Manages downloads.
 *
 * <p>Normally a download manager should be accessed via a {@link DownloadService}. When a download
 * manager is used directly instead, downloads will be initially paused and so must be resumed by
 * calling {@link #resumeDownloads()}.
 *
 * <p>A download manager instance must be accessed only from the thread that created it, unless that
 * thread does not have a {@link Looper}. In that case, it must be accessed only from the
 * application's main thread. Registered listeners will be called on the same thread. In all cases
 * the `Looper` of the thread from which the manager must be accessed can be queried using {@link
 * #getApplicationLooper()}.
 */
@UnstableApi
public final class DownloadManager {

  /** Listener for {@link DownloadManager} events. */
  public interface Listener {

    /**
     * Called when all downloads have been restored.
     *
     * @param downloadManager The reporting instance.
     */
    default void onInitialized(DownloadManager downloadManager) {}

    /**
     * Called when downloads are ({@link #pauseDownloads() paused} or {@link #resumeDownloads()
     * resumed}.
     *
     * @param downloadManager The reporting instance.
     * @param downloadsPaused Whether downloads are currently paused.
     */
    default void onDownloadsPausedChanged(
        DownloadManager downloadManager, boolean downloadsPaused) {}

    /**
     * Called when the state of a download changes.
     *
     * @param downloadManager The reporting instance.
     * @param download The state of the download.
     * @param finalException If the download is transitioning to {@link Download#STATE_FAILED}, this
     *     is the final exception that resulted in the failure.
     */
    default void onDownloadChanged(
        DownloadManager downloadManager, Download download, @Nullable Exception finalException) {}

    /**
     * Called when a download is removed.
     *
     * @param downloadManager The reporting instance.
     * @param download The last state of the download before it was removed.
     */
    default void onDownloadRemoved(DownloadManager downloadManager, Download download) {}

    /**
     * Called when there is no active download left.
     *
     * @param downloadManager The reporting instance.
     */
    default void onIdle(DownloadManager downloadManager) {}

    /**
     * Called when the download requirements state changed.
     *
     * @param downloadManager The reporting instance.
     * @param requirements Requirements needed to be met to start downloads.
     * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not
     *     met, or 0.
     */
    default void onRequirementsStateChanged(
        DownloadManager downloadManager,
        Requirements requirements,
        @Requirements.RequirementFlags int notMetRequirements) {}

    /**
     * Called when there is a change in whether this manager has one or more downloads that are not
     * progressing for the sole reason that the {@link #getRequirements() Requirements} are not met.
     * See {@link #isWaitingForRequirements()} for more information.
     *
     * @param downloadManager The reporting instance.
     * @param waitingForRequirements Whether this manager has one or more downloads that are not
     *     progressing for the sole reason that the {@link #getRequirements() Requirements} are not
     *     met.
     */
    default void onWaitingForRequirementsChanged(
        DownloadManager downloadManager, boolean waitingForRequirements) {}
  }

  /** The default maximum number of parallel downloads. */
  public static final int DEFAULT_MAX_PARALLEL_DOWNLOADS = 3;
  /** The default minimum number of times a download must be retried before failing. */
  public static final int DEFAULT_MIN_RETRY_COUNT = 5;
  /** The default requirement is that the device has network connectivity. */
  public static final Requirements DEFAULT_REQUIREMENTS = new Requirements(Requirements.NETWORK);

  // Messages posted to the main handler.
  private static final int MSG_INITIALIZED = 0;
  private static final int MSG_PROCESSED = 1;
  private static final int MSG_DOWNLOAD_UPDATE = 2;

  // Messages posted to the background handler.
  private static final int MSG_INITIALIZE = 0;
  private static final int MSG_SET_DOWNLOADS_PAUSED = 1;
  private static final int MSG_SET_NOT_MET_REQUIREMENTS = 2;
  private static final int MSG_SET_STOP_REASON = 3;
  private static final int MSG_SET_MAX_PARALLEL_DOWNLOADS = 4;
  private static final int MSG_SET_MIN_RETRY_COUNT = 5;
  private static final int MSG_ADD_DOWNLOAD = 6;
  private static final int MSG_REMOVE_DOWNLOAD = 7;
  private static final int MSG_REMOVE_ALL_DOWNLOADS = 8;
  private static final int MSG_TASK_STOPPED = 9;
  private static final int MSG_CONTENT_LENGTH_CHANGED = 10;
  private static final int MSG_UPDATE_PROGRESS = 11;
  private static final int MSG_RELEASE = 12;

  private static final String TAG = "DownloadManager";

  private final Context context;
  private final WritableDownloadIndex downloadIndex;
  private final Handler applicationHandler;
  private final InternalHandler internalHandler;
  private final RequirementsWatcher.Listener requirementsListener;
  private final CopyOnWriteArraySet<Listener> listeners;

  private int pendingMessages;
  private int activeTaskCount;
  private boolean initialized;
  private boolean downloadsPaused;
  private int maxParallelDownloads;
  private int minRetryCount;
  private int notMetRequirements;
  private boolean waitingForRequirements;
  private List<Download> downloads;
  private RequirementsWatcher requirementsWatcher;

  /**
   * Constructs a {@link DownloadManager}.
   *
   * @param context Any context.
   * @param databaseProvider Provides the SQLite database in which downloads are persisted.
   * @param cache A cache to be used to store downloaded data. The cache should be configured with
   *     an {@link CacheEvictor} that will not evict downloaded content, for example {@link
   *     NoOpCacheEvictor}.
   * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data.
   * @deprecated Use {@link #DownloadManager(Context, DatabaseProvider, Cache, Factory, Executor)}.
   */
  @Deprecated
  public DownloadManager(
      Context context, DatabaseProvider databaseProvider, Cache cache, Factory upstreamFactory) {
    this(context, databaseProvider, cache, upstreamFactory, Runnable::run);
  }

  /**
   * Constructs a {@link DownloadManager}.
   *
   * @param context Any context.
   * @param databaseProvider Provides the SQLite database in which downloads are persisted.
   * @param cache A cache to be used to store downloaded data. The cache should be configured with
   *     an {@link CacheEvictor} that will not evict downloaded content, for example {@link
   *     NoOpCacheEvictor}.
   * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data.
   * @param executor An {@link Executor} used to download data. Passing {@code Runnable::run} will
   *     cause each download task to download data on its own thread. Passing an {@link Executor}
   *     that uses multiple threads will speed up download tasks that can be split into smaller
   *     parts for parallel execution.
   */
  public DownloadManager(
      Context context,
      DatabaseProvider databaseProvider,
      Cache cache,
      Factory upstreamFactory,
      Executor executor) {
    this(
        context,
        new DefaultDownloadIndex(databaseProvider),
        new DefaultDownloaderFactory(
            new CacheDataSource.Factory()
                .setCache(cache)
                .setUpstreamDataSourceFactory(upstreamFactory),
            executor));
  }

  /**
   * Constructs a {@link DownloadManager}.
   *
   * @param context Any context.
   * @param downloadIndex The download index used to hold the download information.
   * @param downloaderFactory A factory for creating {@link Downloader}s.
   */
  public DownloadManager(
      Context context, WritableDownloadIndex downloadIndex, DownloaderFactory downloaderFactory) {
    this.context = context.getApplicationContext();
    this.downloadIndex = downloadIndex;

    maxParallelDownloads = DEFAULT_MAX_PARALLEL_DOWNLOADS;
    minRetryCount = DEFAULT_MIN_RETRY_COUNT;
    downloadsPaused = true;
    downloads = Collections.emptyList();
    listeners = new CopyOnWriteArraySet<>();

    @SuppressWarnings("nullness:methodref.receiver.bound")
    Handler mainHandler = Util.createHandlerForCurrentOrMainLooper(this::handleMainMessage);
    this.applicationHandler = mainHandler;
    HandlerThread internalThread = new HandlerThread("ExoPlayer:DownloadManager");
    internalThread.start();
    internalHandler =
        new InternalHandler(
            internalThread,
            downloadIndex,
            downloaderFactory,
            mainHandler,
            maxParallelDownloads,
            minRetryCount,
            downloadsPaused);

    @SuppressWarnings("nullness:methodref.receiver.bound")
    RequirementsWatcher.Listener requirementsListener = this::onRequirementsStateChanged;
    this.requirementsListener = requirementsListener;
    requirementsWatcher =
        new RequirementsWatcher(context, requirementsListener, DEFAULT_REQUIREMENTS);
    notMetRequirements = requirementsWatcher.start();

    pendingMessages = 1;
    internalHandler
        .obtainMessage(MSG_INITIALIZE, notMetRequirements, /* unused */ 0)
        .sendToTarget();
  }

  /**
   * Returns the {@link Looper} associated with the application thread that's used to access the
   * manager, and on which the manager will call its {@link Listener Listeners}.
   */
  public Looper getApplicationLooper() {
    return applicationHandler.getLooper();
  }

  /** Returns whether the manager has completed initialization. */
  public boolean isInitialized() {
    return initialized;
  }

  /**
   * Returns whether the manager is currently idle. The manager is idle if all downloads are in a
   * terminal state (i.e. completed or failed), or if no progress can be made (e.g. because the
   * download requirements are not met).
   */
  public boolean isIdle() {
    return activeTaskCount == 0 && pendingMessages == 0;
  }

  /**
   * Returns whether this manager has one or more downloads that are not progressing for the sole
   * reason that the {@link #getRequirements() Requirements} are not met. This is true if:
   *
   * <ul>
   *   <li>The {@link #getRequirements() Requirements} are not met.
   *   <li>The downloads are not paused (i.e. {@link #getDownloadsPaused()} is {@code false}).
   *   <li>There are downloads in the {@link Download#STATE_QUEUED queued state}.
   * </ul>
   */
  public boolean isWaitingForRequirements() {
    return waitingForRequirements;
  }

  /**
   * Adds a {@link Listener}.
   *
   * @param listener The listener to be added.
   */
  public void addListener(Listener listener) {
    Assertions.checkNotNull(listener);
    listeners.add(listener);
  }

  /**
   * Removes a {@link Listener}.
   *
   * @param listener The listener to be removed.
   */
  public void removeListener(Listener listener) {
    listeners.remove(listener);
  }

  /** Returns the requirements needed to be met to progress. */
  public Requirements getRequirements() {
    return requirementsWatcher.getRequirements();
  }

  /**
   * Returns the requirements needed for downloads to progress that are not currently met.
   *
   * @return The not met {@link Requirements.RequirementFlags}, or 0 if all requirements are met.
   */
  public @Requirements.RequirementFlags int getNotMetRequirements() {
    return notMetRequirements;
  }

  /**
   * Sets the requirements that need to be met for downloads to progress.
   *
   * @param requirements A {@link Requirements}.
   */
  public void setRequirements(Requirements requirements) {
    if (requirements.equals(requirementsWatcher.getRequirements())) {
      return;
    }
    requirementsWatcher.stop();
    requirementsWatcher = new RequirementsWatcher(context, requirementsListener, requirements);
    int notMetRequirements = requirementsWatcher.start();
    onRequirementsStateChanged(requirementsWatcher, notMetRequirements);
  }

  /** Returns the maximum number of parallel downloads. */
  public int getMaxParallelDownloads() {
    return maxParallelDownloads;
  }

  /**
   * Sets the maximum number of parallel downloads.
   *
   * @param maxParallelDownloads The maximum number of parallel downloads. Must be greater than 0.
   */
  public void setMaxParallelDownloads(@IntRange(from = 1) int maxParallelDownloads) {
    Assertions.checkArgument(maxParallelDownloads > 0);
    if (this.maxParallelDownloads == maxParallelDownloads) {
      return;
    }
    this.maxParallelDownloads = maxParallelDownloads;
    pendingMessages++;
    internalHandler
        .obtainMessage(MSG_SET_MAX_PARALLEL_DOWNLOADS, maxParallelDownloads, /* unused */ 0)
        .sendToTarget();
  }

  /**
   * Returns the minimum number of times that a download will be retried. A download will fail if
   * the specified number of retries is exceeded without any progress being made.
   */
  public int getMinRetryCount() {
    return minRetryCount;
  }

  /**
   * Sets the minimum number of times that a download will be retried. A download will fail if the
   * specified number of retries is exceeded without any progress being made.
   *
   * @param minRetryCount The minimum number of times that a download will be retried.
   */
  public void setMinRetryCount(int minRetryCount) {
    Assertions.checkArgument(minRetryCount >= 0);
    if (this.minRetryCount == minRetryCount) {
      return;
    }
    this.minRetryCount = minRetryCount;
    pendingMessages++;
    internalHandler
        .obtainMessage(MSG_SET_MIN_RETRY_COUNT, minRetryCount, /* unused */ 0)
        .sendToTarget();
  }

  /** Returns the used {@link DownloadIndex}. */
  public DownloadIndex getDownloadIndex() {
    return downloadIndex;
  }

  /**
   * Returns current downloads. Downloads that are in terminal states (i.e. completed or failed) are
   * not included. To query all downloads including those in terminal states, use {@link
   * #getDownloadIndex()} instead.
   */
  public List<Download> getCurrentDownloads() {
    return downloads;
  }

  /** Returns whether downloads are currently paused. */
  public boolean getDownloadsPaused() {
    return downloadsPaused;
  }

  /**
   * Resumes downloads.
   *
   * <p>If the {@link #setRequirements(Requirements) Requirements} are met up to {@link
   * #getMaxParallelDownloads() maxParallelDownloads} will be started, excluding those with non-zero
   * {@link Download#stopReason stopReasons}.
   */
  public void resumeDownloads() {
    setDownloadsPaused(/* downloadsPaused= */ false);
  }

  /**
   * Pauses downloads. Downloads that would otherwise be making progress will transition to {@link
   * Download#STATE_QUEUED}.
   */
  public void pauseDownloads() {
    setDownloadsPaused(/* downloadsPaused= */ true);
  }

  /**
   * Sets the stop reason for one or all downloads. To clear the stop reason, pass {@link
   * Download#STOP_REASON_NONE}.
   *
   * @param id The content id of the download to update, or {@code null} to set the stop reason for
   *     all downloads.
   * @param stopReason The stop reason, or {@link Download#STOP_REASON_NONE}.
   */
  public void setStopReason(@Nullable String id, int stopReason) {
    pendingMessages++;
    internalHandler
        .obtainMessage(MSG_SET_STOP_REASON, stopReason, /* unused */ 0, id)
        .sendToTarget();
  }

  /**
   * Adds a download defined by the given request.
   *
   * @param request The download request.
   */
  public void addDownload(DownloadRequest request) {
    addDownload(request, STOP_REASON_NONE);
  }

  /**
   * Adds a download defined by the given request and with the specified stop reason.
   *
   * @param request The download request.
   * @param stopReason An initial stop reason for the download, or {@link Download#STOP_REASON_NONE}
   *     if the download should be started.
   */
  public void addDownload(DownloadRequest request, int stopReason) {
    pendingMessages++;
    internalHandler
        .obtainMessage(MSG_ADD_DOWNLOAD, stopReason, /* unused */ 0, request)
        .sendToTarget();
  }

  /**
   * Cancels the download with the {@code id} and removes all downloaded data.
   *
   * @param id The unique content id of the download to be started.
   */
  public void removeDownload(String id) {
    pendingMessages++;
    internalHandler.obtainMessage(MSG_REMOVE_DOWNLOAD, id).sendToTarget();
  }

  /** Cancels all pending downloads and removes all downloaded data. */
  public void removeAllDownloads() {
    pendingMessages++;
    internalHandler.obtainMessage(MSG_REMOVE_ALL_DOWNLOADS).sendToTarget();
  }

  /**
   * Stops the downloads and releases resources. Waits until the downloads are persisted to the
   * download index. The manager must not be accessed after this method has been called.
   */
  public void release() {
    synchronized (internalHandler) {
      if (internalHandler.released) {
        return;
      }
      internalHandler.sendEmptyMessage(MSG_RELEASE);
      boolean wasInterrupted = false;
      while (!internalHandler.released) {
        try {
          internalHandler.wait();
        } catch (InterruptedException e) {
          wasInterrupted = true;
        }
      }
      if (wasInterrupted) {
        // Restore the interrupted status.
        Thread.currentThread().interrupt();
      }
      applicationHandler.removeCallbacksAndMessages(/* token= */ null);
      // Reset state.
      downloads = Collections.emptyList();
      pendingMessages = 0;
      activeTaskCount = 0;
      initialized = false;
      notMetRequirements = 0;
      waitingForRequirements = false;
    }
  }

  private void setDownloadsPaused(boolean downloadsPaused) {
    if (this.downloadsPaused == downloadsPaused) {
      return;
    }
    this.downloadsPaused = downloadsPaused;
    pendingMessages++;
    internalHandler
        .obtainMessage(MSG_SET_DOWNLOADS_PAUSED, downloadsPaused ? 1 : 0, /* unused */ 0)
        .sendToTarget();
    boolean waitingForRequirementsChanged = updateWaitingForRequirements();
    for (Listener listener : listeners) {
      listener.onDownloadsPausedChanged(this, downloadsPaused);
    }
    if (waitingForRequirementsChanged) {
      notifyWaitingForRequirementsChanged();
    }
  }

  private void onRequirementsStateChanged(
      RequirementsWatcher requirementsWatcher,
      @Requirements.RequirementFlags int notMetRequirements) {
    Requirements requirements = requirementsWatcher.getRequirements();
    if (this.notMetRequirements != notMetRequirements) {
      this.notMetRequirements = notMetRequirements;
      pendingMessages++;
      internalHandler
          .obtainMessage(MSG_SET_NOT_MET_REQUIREMENTS, notMetRequirements, /* unused */ 0)
          .sendToTarget();
    }
    boolean waitingForRequirementsChanged = updateWaitingForRequirements();
    for (Listener listener : listeners) {
      listener.onRequirementsStateChanged(this, requirements, notMetRequirements);
    }
    if (waitingForRequirementsChanged) {
      notifyWaitingForRequirementsChanged();
    }
  }

  private boolean updateWaitingForRequirements() {
    boolean waitingForRequirements = false;
    if (!downloadsPaused && notMetRequirements != 0) {
      for (int i = 0; i < downloads.size(); i++) {
        if (downloads.get(i).state == STATE_QUEUED) {
          waitingForRequirements = true;
          break;
        }
      }
    }
    boolean waitingForRequirementsChanged = this.waitingForRequirements != waitingForRequirements;
    this.waitingForRequirements = waitingForRequirements;
    return waitingForRequirementsChanged;
  }

  private void notifyWaitingForRequirementsChanged() {
    for (Listener listener : listeners) {
      listener.onWaitingForRequirementsChanged(this, waitingForRequirements);
    }
  }

  // Main thread message handling.

  @SuppressWarnings("unchecked")
  private boolean handleMainMessage(Message message) {
    switch (message.what) {
      case MSG_INITIALIZED:
        List<Download> downloads = (List<Download>) message.obj;
        onInitialized(downloads);
        break;
      case MSG_DOWNLOAD_UPDATE:
        DownloadUpdate update = (DownloadUpdate) message.obj;
        onDownloadUpdate(update);
        break;
      case MSG_PROCESSED:
        int processedMessageCount = message.arg1;
        int activeTaskCount = message.arg2;
        onMessageProcessed(processedMessageCount, activeTaskCount);
        break;
      default:
        throw new IllegalStateException();
    }
    return true;
  }

  private void onInitialized(List<Download> downloads) {
    initialized = true;
    this.downloads = Collections.unmodifiableList(downloads);
    boolean waitingForRequirementsChanged = updateWaitingForRequirements();
    for (Listener listener : listeners) {
      listener.onInitialized(DownloadManager.this);
    }
    if (waitingForRequirementsChanged) {
      notifyWaitingForRequirementsChanged();
    }
  }

  private void onDownloadUpdate(DownloadUpdate update) {
    downloads = Collections.unmodifiableList(update.downloads);
    Download updatedDownload = update.download;
    boolean waitingForRequirementsChanged = updateWaitingForRequirements();
    if (update.isRemove) {
      for (Listener listener : listeners) {
        listener.onDownloadRemoved(this, updatedDownload);
      }
    } else {
      for (Listener listener : listeners) {
        listener.onDownloadChanged(this, updatedDownload, update.finalException);
      }
    }
    if (waitingForRequirementsChanged) {
      notifyWaitingForRequirementsChanged();
    }
  }

  private void onMessageProcessed(int processedMessageCount, int activeTaskCount) {
    this.pendingMessages -= processedMessageCount;
    this.activeTaskCount = activeTaskCount;
    if (isIdle()) {
      for (Listener listener : listeners) {
        listener.onIdle(this);
      }
    }
  }

  /* package */ static Download mergeRequest(
      Download download, DownloadRequest request, int stopReason, long nowMs) {
    @Download.State int state = download.state;
    // Treat the merge as creating a new download if we're currently removing the existing one, or
    // if the existing download is in a terminal state. Else treat the merge as updating the
    // existing download.
    long startTimeMs =
        state == STATE_REMOVING || download.isTerminalState() ? nowMs : download.startTimeMs;
    if (state == STATE_REMOVING || state == STATE_RESTARTING) {
      state = STATE_RESTARTING;
    } else if (stopReason != STOP_REASON_NONE) {
      state = STATE_STOPPED;
    } else {
      state = STATE_QUEUED;
    }
    return new Download(
        download.request.copyWithMergedRequest(request),
        state,
        startTimeMs,
        /* updateTimeMs= */ nowMs,
        /* contentLength= */ C.LENGTH_UNSET,
        stopReason,
        FAILURE_REASON_NONE);
  }

  private static final class InternalHandler extends Handler {

    private static final int UPDATE_PROGRESS_INTERVAL_MS = 5000;

    public boolean released;

    private final HandlerThread thread;
    private final WritableDownloadIndex downloadIndex;
    private final DownloaderFactory downloaderFactory;
    private final Handler mainHandler;
    private final ArrayList<Download> downloads;
    private final HashMap<String, Task> activeTasks;

    private @Requirements.RequirementFlags int notMetRequirements;
    private boolean downloadsPaused;
    private int maxParallelDownloads;
    private int minRetryCount;
    private int activeDownloadTaskCount;

    public InternalHandler(
        HandlerThread thread,
        WritableDownloadIndex downloadIndex,
        DownloaderFactory downloaderFactory,
        Handler mainHandler,
        int maxParallelDownloads,
        int minRetryCount,
        boolean downloadsPaused) {
      super(thread.getLooper());
      this.thread = thread;
      this.downloadIndex = downloadIndex;
      this.downloaderFactory = downloaderFactory;
      this.mainHandler = mainHandler;
      this.maxParallelDownloads = maxParallelDownloads;
      this.minRetryCount = minRetryCount;
      this.downloadsPaused = downloadsPaused;
      downloads = new ArrayList<>();
      activeTasks = new HashMap<>();
    }

    @Override
    public void handleMessage(Message message) {
      boolean processedExternalMessage = true;
      switch (message.what) {
        case MSG_INITIALIZE:
          int notMetRequirements = message.arg1;
          initialize(notMetRequirements);
          break;
        case MSG_SET_DOWNLOADS_PAUSED:
          boolean downloadsPaused = message.arg1 != 0;
          setDownloadsPaused(downloadsPaused);
          break;
        case MSG_SET_NOT_MET_REQUIREMENTS:
          notMetRequirements = message.arg1;
          setNotMetRequirements(notMetRequirements);
          break;
        case MSG_SET_STOP_REASON:
          String id = (String) message.obj;
          int stopReason = message.arg1;
          setStopReason(id, stopReason);
          break;
        case MSG_SET_MAX_PARALLEL_DOWNLOADS:
          int maxParallelDownloads = message.arg1;
          setMaxParallelDownloads(maxParallelDownloads);
          break;
        case MSG_SET_MIN_RETRY_COUNT:
          int minRetryCount = message.arg1;
          setMinRetryCount(minRetryCount);
          break;
        case MSG_ADD_DOWNLOAD:
          DownloadRequest request = (DownloadRequest) message.obj;
          stopReason = message.arg1;
          addDownload(request, stopReason);
          break;
        case MSG_REMOVE_DOWNLOAD:
          id = (String) message.obj;
          removeDownload(id);
          break;
        case MSG_REMOVE_ALL_DOWNLOADS:
          removeAllDownloads();
          break;
        case MSG_TASK_STOPPED:
          Task task = (Task) message.obj;
          onTaskStopped(task);
          processedExternalMessage = false; // This message is posted internally.
          break;
        case MSG_CONTENT_LENGTH_CHANGED:
          task = (Task) message.obj;
          onContentLengthChanged(task, Util.toLong(message.arg1, message.arg2));
          return; // No need to post back to mainHandler.
        case MSG_UPDATE_PROGRESS:
          updateProgress();
          return; // No need to post back to mainHandler.
        case MSG_RELEASE:
          release();
          return; // No need to post back to mainHandler.
        default:
          throw new IllegalStateException();
      }
      mainHandler
          .obtainMessage(MSG_PROCESSED, processedExternalMessage ? 1 : 0, activeTasks.size())
          .sendToTarget();
    }

    private void initialize(int notMetRequirements) {
      this.notMetRequirements = notMetRequirements;
      DownloadCursor cursor = null;
      try {
        downloadIndex.setDownloadingStatesToQueued();
        cursor =
            downloadIndex.getDownloads(
                STATE_QUEUED, STATE_STOPPED, STATE_DOWNLOADING, STATE_REMOVING, STATE_RESTARTING);
        while (cursor.moveToNext()) {
          downloads.add(cursor.getDownload());
        }
      } catch (IOException e) {
        Log.e(TAG, "Failed to load index.", e);
        downloads.clear();
      } finally {
        Util.closeQuietly(cursor);
      }
      // A copy must be used for the message to ensure that subsequent changes to the downloads list
      // are not visible to the main thread when it processes the message.
      ArrayList<Download> downloadsForMessage = new ArrayList<>(downloads);
      mainHandler.obtainMessage(MSG_INITIALIZED, downloadsForMessage).sendToTarget();
      syncTasks();
    }

    private void setDownloadsPaused(boolean downloadsPaused) {
      this.downloadsPaused = downloadsPaused;
      syncTasks();
    }

    private void setNotMetRequirements(@Requirements.RequirementFlags int notMetRequirements) {
      this.notMetRequirements = notMetRequirements;
      syncTasks();
    }

    private void setStopReason(@Nullable String id, int stopReason) {
      if (id == null) {
        for (int i = 0; i < downloads.size(); i++) {
          setStopReason(downloads.get(i), stopReason);
        }
        try {
          // Set the stop reason for downloads in terminal states as well.
          downloadIndex.setStopReason(stopReason);
        } catch (IOException e) {
          Log.e(TAG, "Failed to set manual stop reason", e);
        }
      } else {
        @Nullable Download download = getDownload(id, /* loadFromIndex= */ false);
        if (download != null) {
          setStopReason(download, stopReason);
        } else {
          try {
            // Set the stop reason if the download is in a terminal state.
            downloadIndex.setStopReason(id, stopReason);
          } catch (IOException e) {
            Log.e(TAG, "Failed to set manual stop reason: " + id, e);
          }
        }
      }
      syncTasks();
    }

    private void setStopReason(Download download, int stopReason) {
      if (stopReason == STOP_REASON_NONE) {
        if (download.state == STATE_STOPPED) {
          putDownloadWithState(download, STATE_QUEUED, STOP_REASON_NONE);
        }
      } else if (stopReason != download.stopReason) {
        @Download.State int state = download.state;
        if (state == STATE_QUEUED || state == STATE_DOWNLOADING) {
          state = STATE_STOPPED;
        }
        putDownload(
            new Download(
                download.request,
                state,
                download.startTimeMs,
                /* updateTimeMs= */ System.currentTimeMillis(),
                download.contentLength,
                stopReason,
                FAILURE_REASON_NONE,
                download.progress));
      }
    }

    private void setMaxParallelDownloads(int maxParallelDownloads) {
      this.maxParallelDownloads = maxParallelDownloads;
      syncTasks();
    }

    private void setMinRetryCount(int minRetryCount) {
      this.minRetryCount = minRetryCount;
    }

    private void addDownload(DownloadRequest request, int stopReason) {
      @Nullable Download download = getDownload(request.id, /* loadFromIndex= */ true);
      long nowMs = System.currentTimeMillis();
      if (download != null) {
        putDownload(mergeRequest(download, request, stopReason, nowMs));
      } else {
        putDownload(
            new Download(
                request,
                stopReason != STOP_REASON_NONE ? STATE_STOPPED : STATE_QUEUED,
                /* startTimeMs= */ nowMs,
                /* updateTimeMs= */ nowMs,
                /* contentLength= */ C.LENGTH_UNSET,
                stopReason,
                FAILURE_REASON_NONE));
      }
      syncTasks();
    }

    private void removeDownload(String id) {
      @Nullable Download download = getDownload(id, /* loadFromIndex= */ true);
      if (download == null) {
        Log.e(TAG, "Failed to remove nonexistent download: " + id);
        return;
      }
      putDownloadWithState(download, STATE_REMOVING, STOP_REASON_NONE);
      syncTasks();
    }

    private void removeAllDownloads() {
      List<Download> terminalDownloads = new ArrayList<>();
      try (DownloadCursor cursor = downloadIndex.getDownloads(STATE_COMPLETED, STATE_FAILED)) {
        while (cursor.moveToNext()) {
          terminalDownloads.add(cursor.getDownload());
        }
      } catch (IOException e) {
        Log.e(TAG, "Failed to load downloads.");
      }
      for (int i = 0; i < downloads.size(); i++) {
        downloads.set(i, copyDownloadWithState(downloads.get(i), STATE_REMOVING, STOP_REASON_NONE));
      }
      for (int i = 0; i < terminalDownloads.size(); i++) {
        downloads.add(
            copyDownloadWithState(terminalDownloads.get(i), STATE_REMOVING, STOP_REASON_NONE));
      }
      Collections.sort(downloads, InternalHandler::compareStartTimes);
      try {
        downloadIndex.setStatesToRemoving();
      } catch (IOException e) {
        Log.e(TAG, "Failed to update index.", e);
      }
      ArrayList<Download> updateList = new ArrayList<>(downloads);
      for (int i = 0; i < downloads.size(); i++) {
        DownloadUpdate update =
            new DownloadUpdate(
                downloads.get(i), /* isRemove= */ false, updateList, /* finalException= */ null);
        mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget();
      }
      syncTasks();
    }

    private void release() {
      for (Task task : activeTasks.values()) {
        task.cancel(/* released= */ true);
      }
      try {
        downloadIndex.setDownloadingStatesToQueued();
      } catch (IOException e) {
        Log.e(TAG, "Failed to update index.", e);
      }
      downloads.clear();
      thread.quit();
      synchronized (this) {
        released = true;
        notifyAll();
      }
    }

    // Start and cancel tasks based on the current download and manager states.

    private void syncTasks() {
      int accumulatingDownloadTaskCount = 0;
      for (int i = 0; i < downloads.size(); i++) {
        Download download = downloads.get(i);
        @Nullable Task activeTask = activeTasks.get(download.request.id);
        switch (download.state) {
          case STATE_STOPPED:
            syncStoppedDownload(activeTask);
            break;
          case STATE_QUEUED:
            activeTask = syncQueuedDownload(activeTask, download);
            break;
          case STATE_DOWNLOADING:
            Assertions.checkNotNull(activeTask);
            syncDownloadingDownload(activeTask, download, accumulatingDownloadTaskCount);
            break;
          case STATE_REMOVING:
          case STATE_RESTARTING:
            syncRemovingDownload(activeTask, download);
            break;
          case STATE_COMPLETED:
          case STATE_FAILED:
          default:
            throw new IllegalStateException();
        }
        if (activeTask != null && !activeTask.isRemove) {
          accumulatingDownloadTaskCount++;
        }
      }
    }

    private void syncStoppedDownload(@Nullable Task activeTask) {
      if (activeTask != null) {
        // We have a task, which must be a download task. Cancel it.
        Assertions.checkState(!activeTask.isRemove);
        activeTask.cancel(/* released= */ false);
      }
    }

    @Nullable
    @CheckResult
    private Task syncQueuedDownload(@Nullable Task activeTask, Download download) {
      if (activeTask != null) {
        // We have a task, which must be a download task. If the download state is queued we need to
        // cancel it and start a new one, since a new request has been merged into the download.
        Assertions.checkState(!activeTask.isRemove);
        activeTask.cancel(/* released= */ false);
        return activeTask;
      }

      if (!canDownloadsRun() || activeDownloadTaskCount >= maxParallelDownloads) {
        return null;
      }

      // We can start a download task.
      download = putDownloadWithState(download, STATE_DOWNLOADING, STOP_REASON_NONE);
      Downloader downloader = downloaderFactory.createDownloader(download.request);
      activeTask =
          new Task(
              download.request,
              downloader,
              download.progress,
              /* isRemove= */ false,
              minRetryCount,
              /* internalHandler= */ this);
      activeTasks.put(download.request.id, activeTask);
      if (activeDownloadTaskCount++ == 0) {
        sendEmptyMessageDelayed(MSG_UPDATE_PROGRESS, UPDATE_PROGRESS_INTERVAL_MS);
      }
      activeTask.start();
      return activeTask;
    }

    private void syncDownloadingDownload(
        Task activeTask, Download download, int accumulatingDownloadTaskCount) {
      Assertions.checkState(!activeTask.isRemove);
      if (!canDownloadsRun() || accumulatingDownloadTaskCount >= maxParallelDownloads) {
        putDownloadWithState(download, STATE_QUEUED, STOP_REASON_NONE);
        activeTask.cancel(/* released= */ false);
      }
    }

    private void syncRemovingDownload(@Nullable Task activeTask, Download download) {
      if (activeTask != null) {
        if (!activeTask.isRemove) {
          // Cancel the downloading task.
          activeTask.cancel(/* released= */ false);
        }
        // The activeTask is either a remove task, or a downloading task that we just canceled. In
        // the latter case we need to wait for the task to stop before we start a remove task.
        return;
      }

      // We can start a remove task.
      Downloader downloader = downloaderFactory.createDownloader(download.request);
      activeTask =
          new Task(
              download.request,
              downloader,
              download.progress,
              /* isRemove= */ true,
              minRetryCount,
              /* internalHandler= */ this);
      activeTasks.put(download.request.id, activeTask);
      activeTask.start();
    }

    // Task event processing.

    private void onContentLengthChanged(Task task, long contentLength) {
      String downloadId = task.request.id;
      Download download =
          Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false));
      if (contentLength == download.contentLength || contentLength == C.LENGTH_UNSET) {
        return;
      }
      putDownload(
          new Download(
              download.request,
              download.state,
              download.startTimeMs,
              /* updateTimeMs= */ System.currentTimeMillis(),
              contentLength,
              download.stopReason,
              download.failureReason,
              download.progress));
    }

    private void onTaskStopped(Task task) {
      String downloadId = task.request.id;
      activeTasks.remove(downloadId);

      boolean isRemove = task.isRemove;
      if (!isRemove && --activeDownloadTaskCount == 0) {
        removeMessages(MSG_UPDATE_PROGRESS);
      }

      if (task.isCanceled) {
        syncTasks();
        return;
      }

      @Nullable Exception finalException = task.finalException;
      if (finalException != null) {
        Log.e(TAG, "Task failed: " + task.request + ", " + isRemove, finalException);
      }

      Download download =
          Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false));
      switch (download.state) {
        case STATE_DOWNLOADING:
          Assertions.checkState(!isRemove);
          onDownloadTaskStopped(download, finalException);
          break;
        case STATE_REMOVING:
        case STATE_RESTARTING:
          Assertions.checkState(isRemove);
          onRemoveTaskStopped(download);
          break;
        case STATE_QUEUED:
        case STATE_STOPPED:
        case STATE_COMPLETED:
        case STATE_FAILED:
        default:
          throw new IllegalStateException();
      }

      syncTasks();
    }

    private void onDownloadTaskStopped(Download download, @Nullable Exception finalException) {
      download =
          new Download(
              download.request,
              finalException == null ? STATE_COMPLETED : STATE_FAILED,
              download.startTimeMs,
              /* updateTimeMs= */ System.currentTimeMillis(),
              download.contentLength,
              download.stopReason,
              finalException == null ? FAILURE_REASON_NONE : FAILURE_REASON_UNKNOWN,
              download.progress);
      // The download is now in a terminal state, so should not be in the downloads list.
      downloads.remove(getDownloadIndex(download.request.id));
      // We still need to update the download index and main thread.
      try {
        downloadIndex.putDownload(download);
      } catch (IOException e) {
        Log.e(TAG, "Failed to update index.", e);
      }
      DownloadUpdate update =
          new DownloadUpdate(
              download, /* isRemove= */ false, new ArrayList<>(downloads), finalException);
      mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget();
    }

    private void onRemoveTaskStopped(Download download) {
      if (download.state == STATE_RESTARTING) {
        @Download.State
        int state = download.stopReason == STOP_REASON_NONE ? STATE_QUEUED : STATE_STOPPED;
        putDownloadWithState(download, state, download.stopReason);
        syncTasks();
      } else {
        int removeIndex = getDownloadIndex(download.request.id);
        downloads.remove(removeIndex);
        try {
          downloadIndex.removeDownload(download.request.id);
        } catch (IOException e) {
          Log.e(TAG, "Failed to remove from database");
        }
        DownloadUpdate update =
            new DownloadUpdate(
                download,
                /* isRemove= */ true,
                new ArrayList<>(downloads),
                /* finalException= */ null);
        mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget();
      }
    }

    // Progress updates.

    private void updateProgress() {
      for (int i = 0; i < downloads.size(); i++) {
        Download download = downloads.get(i);
        if (download.state == STATE_DOWNLOADING) {
          try {
            downloadIndex.putDownload(download);
          } catch (IOException e) {
            Log.e(TAG, "Failed to update index.", e);
          }
        }
      }
      sendEmptyMessageDelayed(MSG_UPDATE_PROGRESS, UPDATE_PROGRESS_INTERVAL_MS);
    }

    // Helper methods.

    private boolean canDownloadsRun() {
      return !downloadsPaused && notMetRequirements == 0;
    }

    private Download putDownloadWithState(
        Download download, @Download.State int state, int stopReason) {
      // Downloads in terminal states shouldn't be in the downloads list.
      Assertions.checkState(state != STATE_COMPLETED && state != STATE_FAILED);
      return putDownload(copyDownloadWithState(download, state, stopReason));
    }

    private Download putDownload(Download download) {
      // Downloads in terminal states shouldn't be in the downloads list.
      Assertions.checkState(download.state != STATE_COMPLETED && download.state != STATE_FAILED);
      int changedIndex = getDownloadIndex(download.request.id);
      if (changedIndex == C.INDEX_UNSET) {
        downloads.add(download);
        Collections.sort(downloads, InternalHandler::compareStartTimes);
      } else {
        boolean needsSort = download.startTimeMs != downloads.get(changedIndex).startTimeMs;
        downloads.set(changedIndex, download);
        if (needsSort) {
          Collections.sort(downloads, InternalHandler::compareStartTimes);
        }
      }
      try {
        downloadIndex.putDownload(download);
      } catch (IOException e) {
        Log.e(TAG, "Failed to update index.", e);
      }
      DownloadUpdate update =
          new DownloadUpdate(
              download,
              /* isRemove= */ false,
              new ArrayList<>(downloads),
              /* finalException= */ null);
      mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget();
      return download;
    }

    @Nullable
    private Download getDownload(String id, boolean loadFromIndex) {
      int index = getDownloadIndex(id);
      if (index != C.INDEX_UNSET) {
        return downloads.get(index);
      }
      if (loadFromIndex) {
        try {
          return downloadIndex.getDownload(id);
        } catch (IOException e) {
          Log.e(TAG, "Failed to load download: " + id, e);
        }
      }
      return null;
    }

    private int getDownloadIndex(String id) {
      for (int i = 0; i < downloads.size(); i++) {
        Download download = downloads.get(i);
        if (download.request.id.equals(id)) {
          return i;
        }
      }
      return C.INDEX_UNSET;
    }

    private static Download copyDownloadWithState(
        Download download, @Download.State int state, int stopReason) {
      return new Download(
          download.request,
          state,
          download.startTimeMs,
          /* updateTimeMs= */ System.currentTimeMillis(),
          download.contentLength,
          stopReason,
          FAILURE_REASON_NONE,
          download.progress);
    }

    private static int compareStartTimes(Download first, Download second) {
      return Util.compareLong(first.startTimeMs, second.startTimeMs);
    }
  }

  private static class Task extends Thread implements Downloader.ProgressListener {

    private final DownloadRequest request;
    private final Downloader downloader;
    private final DownloadProgress downloadProgress;
    private final boolean isRemove;
    private final int minRetryCount;

    @Nullable private volatile InternalHandler internalHandler;
    private volatile boolean isCanceled;
    @Nullable private Exception finalException;

    private long contentLength;

    private Task(
        DownloadRequest request,
        Downloader downloader,
        DownloadProgress downloadProgress,
        boolean isRemove,
        int minRetryCount,
        InternalHandler internalHandler) {
      this.request = request;
      this.downloader = downloader;
      this.downloadProgress = downloadProgress;
      this.isRemove = isRemove;
      this.minRetryCount = minRetryCount;
      this.internalHandler = internalHandler;
      contentLength = C.LENGTH_UNSET;
    }

    @SuppressWarnings("nullness:assignment")
    public void cancel(boolean released) {
      if (released) {
        // Download threads are GC roots for as long as they're running. The time taken for
        // cancellation to complete depends on the implementation of the downloader being used. We
        // null the handler reference here so that it doesn't prevent garbage collection of the
        // download manager whilst cancellation is ongoing.
        internalHandler = null;
      }
      if (!isCanceled) {
        isCanceled = true;
        downloader.cancel();
        interrupt();
      }
    }

    // Methods running on download thread.

    @Override
    public void run() {
      try {
        if (isRemove) {
          downloader.remove();
        } else {
          int errorCount = 0;
          long errorPosition = C.LENGTH_UNSET;
          while (!isCanceled) {
            try {
              downloader.download(/* progressListener= */ this);
              break;
            } catch (IOException e) {
              if (!isCanceled) {
                long bytesDownloaded = downloadProgress.bytesDownloaded;
                if (bytesDownloaded != errorPosition) {
                  errorPosition = bytesDownloaded;
                  errorCount = 0;
                }
                if (++errorCount > minRetryCount) {
                  throw e;
                }
                Thread.sleep(getRetryDelayMillis(errorCount));
              }
            }
          }
        }
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      } catch (Exception e) {
        finalException = e;
      }
      @Nullable Handler internalHandler = this.internalHandler;
      if (internalHandler != null) {
        internalHandler.obtainMessage(MSG_TASK_STOPPED, this).sendToTarget();
      }
    }

    @Override
    public void onProgress(long contentLength, long bytesDownloaded, float percentDownloaded) {
      downloadProgress.bytesDownloaded = bytesDownloaded;
      downloadProgress.percentDownloaded = percentDownloaded;
      if (contentLength != this.contentLength) {
        this.contentLength = contentLength;
        @Nullable Handler internalHandler = this.internalHandler;
        if (internalHandler != null) {
          internalHandler
              .obtainMessage(
                  MSG_CONTENT_LENGTH_CHANGED,
                  (int) (contentLength >> 32),
                  (int) contentLength,
                  this)
              .sendToTarget();
        }
      }
    }

    private static int getRetryDelayMillis(int errorCount) {
      return min((errorCount - 1) * 1000, 5000);
    }
  }

  private static final class DownloadUpdate {

    public final Download download;
    public final boolean isRemove;
    public final List<Download> downloads;
    @Nullable public final Exception finalException;

    public DownloadUpdate(
        Download download,
        boolean isRemove,
        List<Download> downloads,
        @Nullable Exception finalException) {
      this.download = download;
      this.isRemove = isRemove;
      this.downloads = downloads;
      this.finalException = finalException;
    }
  }
}