content: /*
   * 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 com.google.android.exoplayer2.offline;
  
  import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_NONE;
  import static com.google.android.exoplayer2.offline.Download.FAILURE_REASON_UNKNOWN;
  import static com.google.android.exoplayer2.offline.Download.STATE_COMPLETED;
  import static com.google.android.exoplayer2.offline.Download.STATE_DOWNLOADING;
  import static com.google.android.exoplayer2.offline.Download.STATE_FAILED;
  import static com.google.android.exoplayer2.offline.Download.STATE_QUEUED;
  import static com.google.android.exoplayer2.offline.Download.STATE_REMOVING;
  import static com.google.android.exoplayer2.offline.Download.STATE_RESTARTING;
  import static com.google.android.exoplayer2.offline.Download.STATE_STOPPED;
  import static com.google.android.exoplayer2.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 com.google.android.exoplayer2.C;
  import com.google.android.exoplayer2.database.DatabaseProvider;
  import com.google.android.exoplayer2.scheduler.Requirements;
  import com.google.android.exoplayer2.scheduler.RequirementsWatcher;
  import com.google.android.exoplayer2.upstream.DataSource;
  import com.google.android.exoplayer2.upstream.DataSource.Factory;
  import com.google.android.exoplayer2.upstream.cache.Cache;
  import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
  import com.google.android.exoplayer2.upstream.cache.CacheEvictor;
  import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor;
  import com.google.android.exoplayer2.util.Assertions;
  import com.google.android.exoplayer2.util.Log;
  import com.google.android.exoplayer2.util.Util;
  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()}.
   */
  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);
        requirementsWatcher.stop();
        // 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;
      private boolean hasActiveRemoveTask;
  
      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;
        }
  
        if (hasActiveRemoveTask) {
          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);
        hasActiveRemoveTask = true;
        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) {
          hasActiveRemoveTask = false;
        } else if (--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;
      }
    }
  }
  