diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java')
-rw-r--r-- | mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java | 1346 |
1 files changed, 1346 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java new file mode 100644 index 0000000000..a6ace12343 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java @@ -0,0 +1,1346 @@ +/* + * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.FAILURE_REASON_NONE; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.FAILURE_REASON_UNKNOWN; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_COMPLETED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_DOWNLOADING; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_FAILED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_QUEUED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_REMOVING; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_RESTARTING; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_STOPPED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STOP_REASON_NONE; + +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.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.Requirements; +import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.RequirementsWatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource.Factory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheEvictor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.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; + +/** + * 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. + */ +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. + */ + default void onDownloadChanged(DownloadManager downloadManager, Download download) {} + + /** + * 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 mainHandler; + 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. + */ + public DownloadManager( + Context context, DatabaseProvider databaseProvider, Cache cache, Factory upstreamFactory) { + this( + context, + new DefaultDownloadIndex(databaseProvider), + new DefaultDownloaderFactory(new DownloaderConstructorHelper(cache, upstreamFactory))); + } + + /** + * 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("methodref.receiver.bound.invalid") + Handler mainHandler = Util.createHandler(this::handleMainMessage); + this.mainHandler = mainHandler; + HandlerThread internalThread = new HandlerThread("DownloadManager file i/o"); + internalThread.start(); + internalHandler = + new InternalHandler( + internalThread, + downloadIndex, + downloaderFactory, + mainHandler, + maxParallelDownloads, + minRetryCount, + downloadsPaused); + + @SuppressWarnings("methodref.receiver.bound.invalid") + 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 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) { + 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. + */ + @Requirements.RequirementFlags + public 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(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(); + } + mainHandler.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); + } + } + 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; + + @Requirements.RequirementFlags private 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); + 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); + } + } 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); + 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)); + } + for (int i = 0; i < terminalDownloads.size(); i++) { + downloads.add(copyDownloadWithState(terminalDownloads.get(i), STATE_REMOVING)); + } + 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); + 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); + 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); + 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 cancelled. 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) { + String downloadId = task.request.id; + long contentLength = task.contentLength; + 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 Throwable finalError = task.finalError; + if (finalError != null) { + Log.e(TAG, "Task failed: " + task.request + ", " + isRemove, finalError); + } + + Download download = + Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false)); + switch (download.state) { + case STATE_DOWNLOADING: + Assertions.checkState(!isRemove); + onDownloadTaskStopped(download, finalError); + 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 Throwable finalError) { + download = + new Download( + download.request, + finalError == null ? STATE_COMPLETED : STATE_FAILED, + download.startTimeMs, + /* updateTimeMs= */ System.currentTimeMillis(), + download.contentLength, + download.stopReason, + finalError == 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)); + mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); + } + + private void onRemoveTaskStopped(Download download) { + if (download.state == STATE_RESTARTING) { + putDownloadWithState( + download, download.stopReason == STOP_REASON_NONE ? STATE_QUEUED : STATE_STOPPED); + 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)); + 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) { + // Downloads in terminal states shouldn't be in the downloads list. This method cannot be used + // to set STATE_STOPPED either, because it doesn't have a stopReason argument. + Assertions.checkState( + state != STATE_COMPLETED && state != STATE_FAILED && state != STATE_STOPPED); + return putDownload(copyDownloadWithState(download, state)); + } + + 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)); + 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) { + return new Download( + download.request, + state, + download.startTimeMs, + /* updateTimeMs= */ System.currentTimeMillis(), + download.contentLength, + /* stopReason= */ 0, + 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 Throwable finalError; + + 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.type.incompatible") + 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 (Throwable e) { + finalError = 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, this).sendToTarget(); + } + } + } + + private static int getRetryDelayMillis(int errorCount) { + return Math.min((errorCount - 1) * 1000, 5000); + } + } + + private static final class DownloadUpdate { + + public final Download download; + public final boolean isRemove; + public final List<Download> downloads; + + public DownloadUpdate(Download download, boolean isRemove, List<Download> downloads) { + this.download = download; + this.isRemove = isRemove; + this.downloads = downloads; + } + } +} |