blob: 37247013e35213a9dc530a31f78b160c0228de9f [file] [log] [blame]
/*
* 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 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 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.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;
/**
* 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("ExoPlayer:DownloadManager");
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;
}
}
}