summaryrefslogtreecommitdiffstats
path: root/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline')
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFile.java164
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFileUpgradeUtil.java120
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java452
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java119
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Download.java164
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadCursor.java129
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadException.java33
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadHelper.java1174
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadIndex.java49
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java1346
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadProgress.java28
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadRequest.java212
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadService.java1049
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Downloader.java60
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java170
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderFactory.java28
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilterableManifest.java36
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilteringManifestParser.java49
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ProgressiveDownloader.java120
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/SegmentDownloader.java279
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/StreamKey.java132
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/WritableDownloadIndex.java87
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/package-info.java19
23 files changed, 6019 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFile.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFile.java
new file mode 100644
index 0000000000..5451ea5530
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFile.java
@@ -0,0 +1,164 @@
+/*
+ * 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 android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.DownloadRequest.UnsupportedRequestException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.AtomicFile;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Loads {@link DownloadRequest DownloadRequests} from legacy action files.
+ *
+ * @deprecated Legacy action files should be merged into download indices using {@link
+ * ActionFileUpgradeUtil}.
+ */
+@Deprecated
+/* package */ final class ActionFile {
+
+ private static final int VERSION = 0;
+
+ private final AtomicFile atomicFile;
+
+ /**
+ * @param actionFile The file from which {@link DownloadRequest DownloadRequests} will be loaded.
+ */
+ public ActionFile(File actionFile) {
+ atomicFile = new AtomicFile(actionFile);
+ }
+
+ /** Returns whether the file or its backup exists. */
+ public boolean exists() {
+ return atomicFile.exists();
+ }
+
+ /** Deletes the action file and its backup. */
+ public void delete() {
+ atomicFile.delete();
+ }
+
+ /**
+ * Loads {@link DownloadRequest DownloadRequests} from the file.
+ *
+ * @return The loaded {@link DownloadRequest DownloadRequests}, or an empty array if the file does
+ * not exist.
+ * @throws IOException If there is an error reading the file.
+ */
+ public DownloadRequest[] load() throws IOException {
+ if (!exists()) {
+ return new DownloadRequest[0];
+ }
+ @Nullable InputStream inputStream = null;
+ try {
+ inputStream = atomicFile.openRead();
+ DataInputStream dataInputStream = new DataInputStream(inputStream);
+ int version = dataInputStream.readInt();
+ if (version > VERSION) {
+ throw new IOException("Unsupported action file version: " + version);
+ }
+ int actionCount = dataInputStream.readInt();
+ ArrayList<DownloadRequest> actions = new ArrayList<>();
+ for (int i = 0; i < actionCount; i++) {
+ try {
+ actions.add(readDownloadRequest(dataInputStream));
+ } catch (UnsupportedRequestException e) {
+ // remove DownloadRequest is not supported. Ignore and continue loading rest.
+ }
+ }
+ return actions.toArray(new DownloadRequest[0]);
+ } finally {
+ Util.closeQuietly(inputStream);
+ }
+ }
+
+ private static DownloadRequest readDownloadRequest(DataInputStream input) throws IOException {
+ String type = input.readUTF();
+ int version = input.readInt();
+
+ Uri uri = Uri.parse(input.readUTF());
+ boolean isRemoveAction = input.readBoolean();
+
+ int dataLength = input.readInt();
+ @Nullable byte[] data;
+ if (dataLength != 0) {
+ data = new byte[dataLength];
+ input.readFully(data);
+ } else {
+ data = null;
+ }
+
+ // Serialized version 0 progressive actions did not contain keys.
+ boolean isLegacyProgressive = version == 0 && DownloadRequest.TYPE_PROGRESSIVE.equals(type);
+ List<StreamKey> keys = new ArrayList<>();
+ if (!isLegacyProgressive) {
+ int keyCount = input.readInt();
+ for (int i = 0; i < keyCount; i++) {
+ keys.add(readKey(type, version, input));
+ }
+ }
+
+ // Serialized version 0 and 1 DASH/HLS/SS actions did not contain a custom cache key.
+ boolean isLegacySegmented =
+ version < 2
+ && (DownloadRequest.TYPE_DASH.equals(type)
+ || DownloadRequest.TYPE_HLS.equals(type)
+ || DownloadRequest.TYPE_SS.equals(type));
+ @Nullable String customCacheKey = null;
+ if (!isLegacySegmented) {
+ customCacheKey = input.readBoolean() ? input.readUTF() : null;
+ }
+
+ // Serialized version 0, 1 and 2 did not contain an id. We need to generate one.
+ String id = version < 3 ? generateDownloadId(uri, customCacheKey) : input.readUTF();
+
+ if (isRemoveAction) {
+ // Remove actions are not supported anymore.
+ throw new UnsupportedRequestException();
+ }
+ return new DownloadRequest(id, type, uri, keys, customCacheKey, data);
+ }
+
+ private static StreamKey readKey(String type, int version, DataInputStream input)
+ throws IOException {
+ int periodIndex;
+ int groupIndex;
+ int trackIndex;
+
+ // Serialized version 0 HLS/SS actions did not contain a period index.
+ if ((DownloadRequest.TYPE_HLS.equals(type) || DownloadRequest.TYPE_SS.equals(type))
+ && version == 0) {
+ periodIndex = 0;
+ groupIndex = input.readInt();
+ trackIndex = input.readInt();
+ } else {
+ periodIndex = input.readInt();
+ groupIndex = input.readInt();
+ trackIndex = input.readInt();
+ }
+ return new StreamKey(periodIndex, groupIndex, trackIndex);
+ }
+
+ private static String generateDownloadId(Uri uri, @Nullable String customCacheKey) {
+ return customCacheKey != null ? customCacheKey : uri.toString();
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFileUpgradeUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFileUpgradeUtil.java
new file mode 100644
index 0000000000..aa66c73e6b
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFileUpgradeUtil.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2019 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.STATE_QUEUED;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import java.io.File;
+import java.io.IOException;
+
+/** Utility class for upgrading legacy action files into {@link DefaultDownloadIndex}. */
+public final class ActionFileUpgradeUtil {
+
+ /** Provides download IDs during action file upgrade. */
+ public interface DownloadIdProvider {
+
+ /**
+ * Returns a download id for given request.
+ *
+ * @param downloadRequest The request for which an ID is required.
+ * @return A corresponding download ID.
+ */
+ String getId(DownloadRequest downloadRequest);
+ }
+
+ private ActionFileUpgradeUtil() {}
+
+ /**
+ * Merges {@link DownloadRequest DownloadRequests} contained in a legacy action file into a {@link
+ * DefaultDownloadIndex}, deleting the action file if the merge is successful or if {@code
+ * deleteOnFailure} is {@code true}.
+ *
+ * <p>This method must not be called while the {@link DefaultDownloadIndex} is being used by a
+ * {@link DownloadManager}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param actionFilePath The action file path.
+ * @param downloadIdProvider A download ID provider, or {@code null}. If {@code null} then ID of
+ * each download will be its custom cache key if one is specified, or else its URL.
+ * @param downloadIndex The index into which the requests will be merged.
+ * @param deleteOnFailure Whether to delete the action file if the merge fails.
+ * @param addNewDownloadsAsCompleted Whether to add new downloads as completed.
+ * @throws IOException If an error occurs loading or merging the requests.
+ */
+ @WorkerThread
+ @SuppressWarnings("deprecation")
+ public static void upgradeAndDelete(
+ File actionFilePath,
+ @Nullable DownloadIdProvider downloadIdProvider,
+ DefaultDownloadIndex downloadIndex,
+ boolean deleteOnFailure,
+ boolean addNewDownloadsAsCompleted)
+ throws IOException {
+ ActionFile actionFile = new ActionFile(actionFilePath);
+ if (actionFile.exists()) {
+ boolean success = false;
+ try {
+ long nowMs = System.currentTimeMillis();
+ for (DownloadRequest request : actionFile.load()) {
+ if (downloadIdProvider != null) {
+ request = request.copyWithId(downloadIdProvider.getId(request));
+ }
+ mergeRequest(request, downloadIndex, addNewDownloadsAsCompleted, nowMs);
+ }
+ success = true;
+ } finally {
+ if (success || deleteOnFailure) {
+ actionFile.delete();
+ }
+ }
+ }
+ }
+
+ /**
+ * Merges a {@link DownloadRequest} into a {@link DefaultDownloadIndex}.
+ *
+ * @param request The request to be merged.
+ * @param downloadIndex The index into which the request will be merged.
+ * @param addNewDownloadAsCompleted Whether to add new downloads as completed.
+ * @throws IOException If an error occurs merging the request.
+ */
+ /* package */ static void mergeRequest(
+ DownloadRequest request,
+ DefaultDownloadIndex downloadIndex,
+ boolean addNewDownloadAsCompleted,
+ long nowMs)
+ throws IOException {
+ @Nullable Download download = downloadIndex.getDownload(request.id);
+ if (download != null) {
+ download = DownloadManager.mergeRequest(download, request, download.stopReason, nowMs);
+ } else {
+ download =
+ new Download(
+ request,
+ addNewDownloadAsCompleted ? Download.STATE_COMPLETED : STATE_QUEUED,
+ /* startTimeMs= */ nowMs,
+ /* updateTimeMs= */ nowMs,
+ /* contentLength= */ C.LENGTH_UNSET,
+ Download.STOP_REASON_NONE,
+ Download.FAILURE_REASON_NONE);
+ }
+ downloadIndex.putDownload(download);
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java
new file mode 100644
index 0000000000..cc1a2873f5
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java
@@ -0,0 +1,452 @@
+/*
+ * Copyright (C) 2019 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 android.content.ContentValues;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.net.Uri;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.database.VersionTable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.FailureReason;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.State;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.List;
+
+/** A {@link DownloadIndex} that uses SQLite to persist {@link Download Downloads}. */
+public final class DefaultDownloadIndex implements WritableDownloadIndex {
+
+ private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "Downloads";
+
+ @VisibleForTesting /* package */ static final int TABLE_VERSION = 2;
+
+ private static final String COLUMN_ID = "id";
+ private static final String COLUMN_TYPE = "title";
+ private static final String COLUMN_URI = "uri";
+ private static final String COLUMN_STREAM_KEYS = "stream_keys";
+ private static final String COLUMN_CUSTOM_CACHE_KEY = "custom_cache_key";
+ private static final String COLUMN_DATA = "data";
+ private static final String COLUMN_STATE = "state";
+ private static final String COLUMN_START_TIME_MS = "start_time_ms";
+ private static final String COLUMN_UPDATE_TIME_MS = "update_time_ms";
+ private static final String COLUMN_CONTENT_LENGTH = "content_length";
+ private static final String COLUMN_STOP_REASON = "stop_reason";
+ private static final String COLUMN_FAILURE_REASON = "failure_reason";
+ private static final String COLUMN_PERCENT_DOWNLOADED = "percent_downloaded";
+ private static final String COLUMN_BYTES_DOWNLOADED = "bytes_downloaded";
+
+ private static final int COLUMN_INDEX_ID = 0;
+ private static final int COLUMN_INDEX_TYPE = 1;
+ private static final int COLUMN_INDEX_URI = 2;
+ private static final int COLUMN_INDEX_STREAM_KEYS = 3;
+ private static final int COLUMN_INDEX_CUSTOM_CACHE_KEY = 4;
+ private static final int COLUMN_INDEX_DATA = 5;
+ private static final int COLUMN_INDEX_STATE = 6;
+ private static final int COLUMN_INDEX_START_TIME_MS = 7;
+ private static final int COLUMN_INDEX_UPDATE_TIME_MS = 8;
+ private static final int COLUMN_INDEX_CONTENT_LENGTH = 9;
+ private static final int COLUMN_INDEX_STOP_REASON = 10;
+ private static final int COLUMN_INDEX_FAILURE_REASON = 11;
+ private static final int COLUMN_INDEX_PERCENT_DOWNLOADED = 12;
+ private static final int COLUMN_INDEX_BYTES_DOWNLOADED = 13;
+
+ private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?";
+ private static final String WHERE_STATE_IS_DOWNLOADING =
+ COLUMN_STATE + " = " + Download.STATE_DOWNLOADING;
+ private static final String WHERE_STATE_IS_TERMINAL =
+ getStateQuery(Download.STATE_COMPLETED, Download.STATE_FAILED);
+
+ private static final String[] COLUMNS =
+ new String[] {
+ COLUMN_ID,
+ COLUMN_TYPE,
+ COLUMN_URI,
+ COLUMN_STREAM_KEYS,
+ COLUMN_CUSTOM_CACHE_KEY,
+ COLUMN_DATA,
+ COLUMN_STATE,
+ COLUMN_START_TIME_MS,
+ COLUMN_UPDATE_TIME_MS,
+ COLUMN_CONTENT_LENGTH,
+ COLUMN_STOP_REASON,
+ COLUMN_FAILURE_REASON,
+ COLUMN_PERCENT_DOWNLOADED,
+ COLUMN_BYTES_DOWNLOADED,
+ };
+
+ private static final String TABLE_SCHEMA =
+ "("
+ + COLUMN_ID
+ + " TEXT PRIMARY KEY NOT NULL,"
+ + COLUMN_TYPE
+ + " TEXT NOT NULL,"
+ + COLUMN_URI
+ + " TEXT NOT NULL,"
+ + COLUMN_STREAM_KEYS
+ + " TEXT NOT NULL,"
+ + COLUMN_CUSTOM_CACHE_KEY
+ + " TEXT,"
+ + COLUMN_DATA
+ + " BLOB NOT NULL,"
+ + COLUMN_STATE
+ + " INTEGER NOT NULL,"
+ + COLUMN_START_TIME_MS
+ + " INTEGER NOT NULL,"
+ + COLUMN_UPDATE_TIME_MS
+ + " INTEGER NOT NULL,"
+ + COLUMN_CONTENT_LENGTH
+ + " INTEGER NOT NULL,"
+ + COLUMN_STOP_REASON
+ + " INTEGER NOT NULL,"
+ + COLUMN_FAILURE_REASON
+ + " INTEGER NOT NULL,"
+ + COLUMN_PERCENT_DOWNLOADED
+ + " REAL NOT NULL,"
+ + COLUMN_BYTES_DOWNLOADED
+ + " INTEGER NOT NULL)";
+
+ private static final String TRUE = "1";
+
+ private final String name;
+ private final String tableName;
+ private final DatabaseProvider databaseProvider;
+
+ private boolean initialized;
+
+ /**
+ * Creates an instance that stores the {@link Download Downloads} in an SQLite database provided
+ * by a {@link DatabaseProvider}.
+ *
+ * <p>Equivalent to calling {@link #DefaultDownloadIndex(DatabaseProvider, String)} with {@code
+ * name=""}.
+ *
+ * <p>Applications that only have one download index may use this constructor. Applications that
+ * have multiple download indices should call {@link #DefaultDownloadIndex(DatabaseProvider,
+ * String)} to specify a unique name for each index.
+ *
+ * @param databaseProvider Provides the SQLite database in which downloads are persisted.
+ */
+ public DefaultDownloadIndex(DatabaseProvider databaseProvider) {
+ this(databaseProvider, "");
+ }
+
+ /**
+ * Creates an instance that stores the {@link Download Downloads} in an SQLite database provided
+ * by a {@link DatabaseProvider}.
+ *
+ * @param databaseProvider Provides the SQLite database in which downloads are persisted.
+ * @param name The name of the index. This name is incorporated into the names of the SQLite
+ * tables in which downloads are persisted.
+ */
+ public DefaultDownloadIndex(DatabaseProvider databaseProvider, String name) {
+ this.name = name;
+ this.databaseProvider = databaseProvider;
+ tableName = TABLE_PREFIX + name;
+ }
+
+ @Override
+ @Nullable
+ public Download getDownload(String id) throws DatabaseIOException {
+ ensureInitialized();
+ try (Cursor cursor = getCursor(WHERE_ID_EQUALS, new String[] {id})) {
+ if (cursor.getCount() == 0) {
+ return null;
+ }
+ cursor.moveToNext();
+ return getDownloadForCurrentRow(cursor);
+ } catch (SQLiteException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public DownloadCursor getDownloads(@Download.State int... states) throws DatabaseIOException {
+ ensureInitialized();
+ Cursor cursor = getCursor(getStateQuery(states), /* selectionArgs= */ null);
+ return new DownloadCursorImpl(cursor);
+ }
+
+ @Override
+ public void putDownload(Download download) throws DatabaseIOException {
+ ensureInitialized();
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_ID, download.request.id);
+ values.put(COLUMN_TYPE, download.request.type);
+ values.put(COLUMN_URI, download.request.uri.toString());
+ values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(download.request.streamKeys));
+ values.put(COLUMN_CUSTOM_CACHE_KEY, download.request.customCacheKey);
+ values.put(COLUMN_DATA, download.request.data);
+ values.put(COLUMN_STATE, download.state);
+ values.put(COLUMN_START_TIME_MS, download.startTimeMs);
+ values.put(COLUMN_UPDATE_TIME_MS, download.updateTimeMs);
+ values.put(COLUMN_CONTENT_LENGTH, download.contentLength);
+ values.put(COLUMN_STOP_REASON, download.stopReason);
+ values.put(COLUMN_FAILURE_REASON, download.failureReason);
+ values.put(COLUMN_PERCENT_DOWNLOADED, download.getPercentDownloaded());
+ values.put(COLUMN_BYTES_DOWNLOADED, download.getBytesDownloaded());
+ try {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values);
+ } catch (SQLiteException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void removeDownload(String id) throws DatabaseIOException {
+ ensureInitialized();
+ try {
+ databaseProvider.getWritableDatabase().delete(tableName, WHERE_ID_EQUALS, new String[] {id});
+ } catch (SQLiteException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void setDownloadingStatesToQueued() throws DatabaseIOException {
+ ensureInitialized();
+ try {
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_STATE, Download.STATE_QUEUED);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.update(tableName, values, WHERE_STATE_IS_DOWNLOADING, /* whereArgs= */ null);
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void setStatesToRemoving() throws DatabaseIOException {
+ ensureInitialized();
+ try {
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_STATE, Download.STATE_REMOVING);
+ // Only downloads in STATE_FAILED are allowed a failure reason, so we need to clear it here in
+ // case we're moving downloads from STATE_FAILED to STATE_REMOVING.
+ values.put(COLUMN_FAILURE_REASON, Download.FAILURE_REASON_NONE);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.update(tableName, values, /* whereClause= */ null, /* whereArgs= */ null);
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void setStopReason(int stopReason) throws DatabaseIOException {
+ ensureInitialized();
+ try {
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_STOP_REASON, stopReason);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.update(tableName, values, WHERE_STATE_IS_TERMINAL, /* whereArgs= */ null);
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ @Override
+ public void setStopReason(String id, int stopReason) throws DatabaseIOException {
+ ensureInitialized();
+ try {
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_STOP_REASON, stopReason);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.update(
+ tableName,
+ values,
+ WHERE_STATE_IS_TERMINAL + " AND " + WHERE_ID_EQUALS,
+ new String[] {id});
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ private void ensureInitialized() throws DatabaseIOException {
+ if (initialized) {
+ return;
+ }
+ try {
+ SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase();
+ int version = VersionTable.getVersion(readableDatabase, VersionTable.FEATURE_OFFLINE, name);
+ if (version != TABLE_VERSION) {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransactionNonExclusive();
+ try {
+ VersionTable.setVersion(
+ writableDatabase, VersionTable.FEATURE_OFFLINE, name, TABLE_VERSION);
+ writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName);
+ writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA);
+ writableDatabase.setTransactionSuccessful();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ }
+ initialized = true;
+ } catch (SQLException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ // incompatible types in argument.
+ @SuppressWarnings("nullness:argument.type.incompatible")
+ private Cursor getCursor(String selection, @Nullable String[] selectionArgs)
+ throws DatabaseIOException {
+ try {
+ String sortOrder = COLUMN_START_TIME_MS + " ASC";
+ return databaseProvider
+ .getReadableDatabase()
+ .query(
+ tableName,
+ COLUMNS,
+ selection,
+ selectionArgs,
+ /* groupBy= */ null,
+ /* having= */ null,
+ sortOrder);
+ } catch (SQLiteException e) {
+ throw new DatabaseIOException(e);
+ }
+ }
+
+ private static String getStateQuery(@Download.State int... states) {
+ if (states.length == 0) {
+ return TRUE;
+ }
+ StringBuilder selectionBuilder = new StringBuilder();
+ selectionBuilder.append(COLUMN_STATE).append(" IN (");
+ for (int i = 0; i < states.length; i++) {
+ if (i > 0) {
+ selectionBuilder.append(',');
+ }
+ selectionBuilder.append(states[i]);
+ }
+ selectionBuilder.append(')');
+ return selectionBuilder.toString();
+ }
+
+ private static Download getDownloadForCurrentRow(Cursor cursor) {
+ DownloadRequest request =
+ new DownloadRequest(
+ /* id= */ cursor.getString(COLUMN_INDEX_ID),
+ /* type= */ cursor.getString(COLUMN_INDEX_TYPE),
+ /* uri= */ Uri.parse(cursor.getString(COLUMN_INDEX_URI)),
+ /* streamKeys= */ decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS)),
+ /* customCacheKey= */ cursor.getString(COLUMN_INDEX_CUSTOM_CACHE_KEY),
+ /* data= */ cursor.getBlob(COLUMN_INDEX_DATA));
+ DownloadProgress downloadProgress = new DownloadProgress();
+ downloadProgress.bytesDownloaded = cursor.getLong(COLUMN_INDEX_BYTES_DOWNLOADED);
+ downloadProgress.percentDownloaded = cursor.getFloat(COLUMN_INDEX_PERCENT_DOWNLOADED);
+ @State int state = cursor.getInt(COLUMN_INDEX_STATE);
+ // It's possible the database contains failure reasons for non-failed downloads, which is
+ // invalid. Clear them here. See https://github.com/google/ExoPlayer/issues/6785.
+ @FailureReason
+ int failureReason =
+ state == Download.STATE_FAILED
+ ? cursor.getInt(COLUMN_INDEX_FAILURE_REASON)
+ : Download.FAILURE_REASON_NONE;
+ return new Download(
+ request,
+ state,
+ /* startTimeMs= */ cursor.getLong(COLUMN_INDEX_START_TIME_MS),
+ /* updateTimeMs= */ cursor.getLong(COLUMN_INDEX_UPDATE_TIME_MS),
+ /* contentLength= */ cursor.getLong(COLUMN_INDEX_CONTENT_LENGTH),
+ /* stopReason= */ cursor.getInt(COLUMN_INDEX_STOP_REASON),
+ failureReason,
+ downloadProgress);
+ }
+
+ private static String encodeStreamKeys(List<StreamKey> streamKeys) {
+ StringBuilder stringBuilder = new StringBuilder();
+ for (int i = 0; i < streamKeys.size(); i++) {
+ StreamKey streamKey = streamKeys.get(i);
+ stringBuilder
+ .append(streamKey.periodIndex)
+ .append('.')
+ .append(streamKey.groupIndex)
+ .append('.')
+ .append(streamKey.trackIndex)
+ .append(',');
+ }
+ if (stringBuilder.length() > 0) {
+ stringBuilder.setLength(stringBuilder.length() - 1);
+ }
+ return stringBuilder.toString();
+ }
+
+ private static List<StreamKey> decodeStreamKeys(String encodedStreamKeys) {
+ ArrayList<StreamKey> streamKeys = new ArrayList<>();
+ if (encodedStreamKeys.isEmpty()) {
+ return streamKeys;
+ }
+ String[] streamKeysStrings = Util.split(encodedStreamKeys, ",");
+ for (String streamKeysString : streamKeysStrings) {
+ String[] indices = Util.split(streamKeysString, "\\.");
+ Assertions.checkState(indices.length == 3);
+ streamKeys.add(
+ new StreamKey(
+ Integer.parseInt(indices[0]),
+ Integer.parseInt(indices[1]),
+ Integer.parseInt(indices[2])));
+ }
+ return streamKeys;
+ }
+
+ private static final class DownloadCursorImpl implements DownloadCursor {
+
+ private final Cursor cursor;
+
+ private DownloadCursorImpl(Cursor cursor) {
+ this.cursor = cursor;
+ }
+
+ @Override
+ public Download getDownload() {
+ return getDownloadForCurrentRow(cursor);
+ }
+
+ @Override
+ public int getCount() {
+ return cursor.getCount();
+ }
+
+ @Override
+ public int getPosition() {
+ return cursor.getPosition();
+ }
+
+ @Override
+ public boolean moveToPosition(int position) {
+ return cursor.moveToPosition(position);
+ }
+
+ @Override
+ public void close() {
+ cursor.close();
+ }
+
+ @Override
+ public boolean isClosed() {
+ return cursor.isClosed();
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java
new file mode 100644
index 0000000000..6391af8a95
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2018 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 android.net.Uri;
+import androidx.annotation.Nullable;
+import java.lang.reflect.Constructor;
+import java.util.List;
+
+/**
+ * Default {@link DownloaderFactory}, supporting creation of progressive, DASH, HLS and
+ * SmoothStreaming downloaders. Note that for the latter three, the corresponding library module
+ * must be built into the application.
+ */
+public class DefaultDownloaderFactory implements DownloaderFactory {
+
+ @Nullable private static final Constructor<? extends Downloader> DASH_DOWNLOADER_CONSTRUCTOR;
+ @Nullable private static final Constructor<? extends Downloader> HLS_DOWNLOADER_CONSTRUCTOR;
+ @Nullable private static final Constructor<? extends Downloader> SS_DOWNLOADER_CONSTRUCTOR;
+
+ static {
+ Constructor<? extends Downloader> dashDownloaderConstructor = null;
+ try {
+ // LINT.IfChange
+ dashDownloaderConstructor =
+ getDownloaderConstructor(
+ Class.forName("com.google.android.exoplayer2.source.dash.offline.DashDownloader"));
+ // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
+ } catch (ClassNotFoundException e) {
+ // Expected if the app was built without the DASH module.
+ }
+ DASH_DOWNLOADER_CONSTRUCTOR = dashDownloaderConstructor;
+ Constructor<? extends Downloader> hlsDownloaderConstructor = null;
+ try {
+ // LINT.IfChange
+ hlsDownloaderConstructor =
+ getDownloaderConstructor(
+ Class.forName("com.google.android.exoplayer2.source.hls.offline.HlsDownloader"));
+ // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
+ } catch (ClassNotFoundException e) {
+ // Expected if the app was built without the HLS module.
+ }
+ HLS_DOWNLOADER_CONSTRUCTOR = hlsDownloaderConstructor;
+ Constructor<? extends Downloader> ssDownloaderConstructor = null;
+ try {
+ // LINT.IfChange
+ ssDownloaderConstructor =
+ getDownloaderConstructor(
+ Class.forName(
+ "com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader"));
+ // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
+ } catch (ClassNotFoundException e) {
+ // Expected if the app was built without the SmoothStreaming module.
+ }
+ SS_DOWNLOADER_CONSTRUCTOR = ssDownloaderConstructor;
+ }
+
+ private final DownloaderConstructorHelper downloaderConstructorHelper;
+
+ /** @param downloaderConstructorHelper A helper for instantiating downloaders. */
+ public DefaultDownloaderFactory(DownloaderConstructorHelper downloaderConstructorHelper) {
+ this.downloaderConstructorHelper = downloaderConstructorHelper;
+ }
+
+ @Override
+ public Downloader createDownloader(DownloadRequest request) {
+ switch (request.type) {
+ case DownloadRequest.TYPE_PROGRESSIVE:
+ return new ProgressiveDownloader(
+ request.uri, request.customCacheKey, downloaderConstructorHelper);
+ case DownloadRequest.TYPE_DASH:
+ return createDownloader(request, DASH_DOWNLOADER_CONSTRUCTOR);
+ case DownloadRequest.TYPE_HLS:
+ return createDownloader(request, HLS_DOWNLOADER_CONSTRUCTOR);
+ case DownloadRequest.TYPE_SS:
+ return createDownloader(request, SS_DOWNLOADER_CONSTRUCTOR);
+ default:
+ throw new IllegalArgumentException("Unsupported type: " + request.type);
+ }
+ }
+
+ private Downloader createDownloader(
+ DownloadRequest request, @Nullable Constructor<? extends Downloader> constructor) {
+ if (constructor == null) {
+ throw new IllegalStateException("Module missing for: " + request.type);
+ }
+ try {
+ return constructor.newInstance(request.uri, request.streamKeys, downloaderConstructorHelper);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to instantiate downloader for: " + request.type, e);
+ }
+ }
+
+ // LINT.IfChange
+ private static Constructor<? extends Downloader> getDownloaderConstructor(Class<?> clazz) {
+ try {
+ return clazz
+ .asSubclass(Downloader.class)
+ .getConstructor(Uri.class, List.class, DownloaderConstructorHelper.class);
+ } catch (NoSuchMethodException e) {
+ // The downloader is present, but the expected constructor is missing.
+ throw new RuntimeException("Downloader constructor missing", e);
+ }
+ }
+ // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Download.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Download.java
new file mode 100644
index 0000000000..a3bc253a6e
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Download.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2018 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 androidx.annotation.IntDef;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/** Represents state of a download. */
+public final class Download {
+
+ /**
+ * Download states. One of {@link #STATE_QUEUED}, {@link #STATE_STOPPED}, {@link
+ * #STATE_DOWNLOADING}, {@link #STATE_COMPLETED}, {@link #STATE_FAILED}, {@link #STATE_REMOVING}
+ * or {@link #STATE_RESTARTING}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ STATE_QUEUED,
+ STATE_STOPPED,
+ STATE_DOWNLOADING,
+ STATE_COMPLETED,
+ STATE_FAILED,
+ STATE_REMOVING,
+ STATE_RESTARTING
+ })
+ public @interface State {}
+ // Important: These constants are persisted into DownloadIndex. Do not change them.
+ /**
+ * The download is waiting to be started. A download may be queued because the {@link
+ * DownloadManager}
+ *
+ * <ul>
+ * <li>Is {@link DownloadManager#getDownloadsPaused() paused}
+ * <li>Has {@link DownloadManager#getRequirements() Requirements} that are not met
+ * <li>Has already started {@link DownloadManager#getMaxParallelDownloads()
+ * maxParallelDownloads}
+ * </ul>
+ */
+ public static final int STATE_QUEUED = 0;
+ /** The download is stopped for a specified {@link #stopReason}. */
+ public static final int STATE_STOPPED = 1;
+ /** The download is currently started. */
+ public static final int STATE_DOWNLOADING = 2;
+ /** The download completed. */
+ public static final int STATE_COMPLETED = 3;
+ /** The download failed. */
+ public static final int STATE_FAILED = 4;
+ /** The download is being removed. */
+ public static final int STATE_REMOVING = 5;
+ /** The download will restart after all downloaded data is removed. */
+ public static final int STATE_RESTARTING = 7;
+
+ /** Failure reasons. Either {@link #FAILURE_REASON_NONE} or {@link #FAILURE_REASON_UNKNOWN}. */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({FAILURE_REASON_NONE, FAILURE_REASON_UNKNOWN})
+ public @interface FailureReason {}
+ /** The download isn't failed. */
+ public static final int FAILURE_REASON_NONE = 0;
+ /** The download is failed because of unknown reason. */
+ public static final int FAILURE_REASON_UNKNOWN = 1;
+
+ /** The download isn't stopped. */
+ public static final int STOP_REASON_NONE = 0;
+
+ /** The download request. */
+ public final DownloadRequest request;
+ /** The state of the download. */
+ @State public final int state;
+ /** The first time when download entry is created. */
+ public final long startTimeMs;
+ /** The last update time. */
+ public final long updateTimeMs;
+ /** The total size of the content in bytes, or {@link C#LENGTH_UNSET} if unknown. */
+ public final long contentLength;
+ /** The reason the download is stopped, or {@link #STOP_REASON_NONE}. */
+ public final int stopReason;
+ /**
+ * If {@link #state} is {@link #STATE_FAILED} then this is the cause, otherwise {@link
+ * #FAILURE_REASON_NONE}.
+ */
+ @FailureReason public final int failureReason;
+
+ /* package */ final DownloadProgress progress;
+
+ public Download(
+ DownloadRequest request,
+ @State int state,
+ long startTimeMs,
+ long updateTimeMs,
+ long contentLength,
+ int stopReason,
+ @FailureReason int failureReason) {
+ this(
+ request,
+ state,
+ startTimeMs,
+ updateTimeMs,
+ contentLength,
+ stopReason,
+ failureReason,
+ new DownloadProgress());
+ }
+
+ public Download(
+ DownloadRequest request,
+ @State int state,
+ long startTimeMs,
+ long updateTimeMs,
+ long contentLength,
+ int stopReason,
+ @FailureReason int failureReason,
+ DownloadProgress progress) {
+ Assertions.checkNotNull(progress);
+ Assertions.checkArgument((failureReason == FAILURE_REASON_NONE) == (state != STATE_FAILED));
+ if (stopReason != 0) {
+ Assertions.checkArgument(state != STATE_DOWNLOADING && state != STATE_QUEUED);
+ }
+ this.request = request;
+ this.state = state;
+ this.startTimeMs = startTimeMs;
+ this.updateTimeMs = updateTimeMs;
+ this.contentLength = contentLength;
+ this.stopReason = stopReason;
+ this.failureReason = failureReason;
+ this.progress = progress;
+ }
+
+ /** Returns whether the download is completed or failed. These are terminal states. */
+ public boolean isTerminalState() {
+ return state == STATE_COMPLETED || state == STATE_FAILED;
+ }
+
+ /** Returns the total number of downloaded bytes. */
+ public long getBytesDownloaded() {
+ return progress.bytesDownloaded;
+ }
+
+ /**
+ * Returns the estimated download percentage, or {@link C#PERCENTAGE_UNSET} if no estimate is
+ * available.
+ */
+ public float getPercentDownloaded() {
+ return progress.percentDownloaded;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadCursor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadCursor.java
new file mode 100644
index 0000000000..9693e43002
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadCursor.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2019 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 java.io.Closeable;
+
+/** Provides random read-write access to the result set returned by a database query. */
+public interface DownloadCursor extends Closeable {
+
+ /** Returns the download at the current position. */
+ Download getDownload();
+
+ /** Returns the numbers of downloads in the cursor. */
+ int getCount();
+
+ /**
+ * Returns the current position of the cursor in the download set. The value is zero-based. When
+ * the download set is first returned the cursor will be at positon -1, which is before the first
+ * download. After the last download is returned another call to next() will leave the cursor past
+ * the last entry, at a position of count().
+ *
+ * @return the current cursor position.
+ */
+ int getPosition();
+
+ /**
+ * Move the cursor to an absolute position. The valid range of values is -1 &lt;= position &lt;=
+ * count.
+ *
+ * <p>This method will return true if the request destination was reachable, otherwise, it returns
+ * false.
+ *
+ * @param position the zero-based position to move to.
+ * @return whether the requested move fully succeeded.
+ */
+ boolean moveToPosition(int position);
+
+ /**
+ * Move the cursor to the first download.
+ *
+ * <p>This method will return false if the cursor is empty.
+ *
+ * @return whether the move succeeded.
+ */
+ default boolean moveToFirst() {
+ return moveToPosition(0);
+ }
+
+ /**
+ * Move the cursor to the last download.
+ *
+ * <p>This method will return false if the cursor is empty.
+ *
+ * @return whether the move succeeded.
+ */
+ default boolean moveToLast() {
+ return moveToPosition(getCount() - 1);
+ }
+
+ /**
+ * Move the cursor to the next download.
+ *
+ * <p>This method will return false if the cursor is already past the last entry in the result
+ * set.
+ *
+ * @return whether the move succeeded.
+ */
+ default boolean moveToNext() {
+ return moveToPosition(getPosition() + 1);
+ }
+
+ /**
+ * Move the cursor to the previous download.
+ *
+ * <p>This method will return false if the cursor is already before the first entry in the result
+ * set.
+ *
+ * @return whether the move succeeded.
+ */
+ default boolean moveToPrevious() {
+ return moveToPosition(getPosition() - 1);
+ }
+
+ /** Returns whether the cursor is pointing to the first download. */
+ default boolean isFirst() {
+ return getPosition() == 0 && getCount() != 0;
+ }
+
+ /** Returns whether the cursor is pointing to the last download. */
+ default boolean isLast() {
+ int count = getCount();
+ return getPosition() == (count - 1) && count != 0;
+ }
+
+ /** Returns whether the cursor is pointing to the position before the first download. */
+ default boolean isBeforeFirst() {
+ if (getCount() == 0) {
+ return true;
+ }
+ return getPosition() == -1;
+ }
+
+ /** Returns whether the cursor is pointing to the position after the last download. */
+ default boolean isAfterLast() {
+ if (getCount() == 0) {
+ return true;
+ }
+ return getPosition() == getCount();
+ }
+
+ /** Returns whether the cursor is closed */
+ boolean isClosed();
+
+ @Override
+ void close();
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadException.java
new file mode 100644
index 0000000000..cd95b5f922
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadException.java
@@ -0,0 +1,33 @@
+/*
+ * 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 java.io.IOException;
+
+/** Thrown on an error during downloading. */
+public final class DownloadException extends IOException {
+
+ /** @param message The message for the exception. */
+ public DownloadException(String message) {
+ super(message);
+ }
+
+ /** @param cause The cause for the exception. */
+ public DownloadException(Throwable cause) {
+ super(cause);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadHelper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadHelper.java
new file mode 100644
index 0000000000..6070b3a80f
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadHelper.java
@@ -0,0 +1,1174 @@
+/*
+ * Copyright (C) 2018 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 android.content.Context;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.util.SparseIntArray;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RenderersFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ProgressiveMediaSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.BaseTrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector.Parameters;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectorResult;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter;
+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.DefaultAllocator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/**
+ * A helper for initializing and removing downloads.
+ *
+ * <p>The helper extracts track information from the media, selects tracks for downloading, and
+ * creates {@link DownloadRequest download requests} based on the selected tracks.
+ *
+ * <p>A typical usage of DownloadHelper follows these steps:
+ *
+ * <ol>
+ * <li>Build the helper using one of the {@code forXXX} methods.
+ * <li>Prepare the helper using {@link #prepare(Callback)} and wait for the callback.
+ * <li>Optional: Inspect the selected tracks using {@link #getMappedTrackInfo(int)} and {@link
+ * #getTrackSelections(int, int)}, and make adjustments using {@link
+ * #clearTrackSelections(int)}, {@link #replaceTrackSelections(int, Parameters)} and {@link
+ * #addTrackSelection(int, Parameters)}.
+ * <li>Create a download request for the selected track using {@link #getDownloadRequest(byte[])}.
+ * <li>Release the helper using {@link #release()}.
+ * </ol>
+ */
+public final class DownloadHelper {
+
+ /**
+ * Default track selection parameters for downloading, but without any {@link Context}
+ * constraints.
+ *
+ * <p>If possible, use {@link #getDefaultTrackSelectorParameters(Context)} instead.
+ *
+ * @see Parameters#DEFAULT_WITHOUT_CONTEXT
+ */
+ public static final Parameters DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT =
+ Parameters.DEFAULT_WITHOUT_CONTEXT.buildUpon().setForceHighestSupportedBitrate(true).build();
+
+ /**
+ * @deprecated This instance does not have {@link Context} constraints. Use {@link
+ * #getDefaultTrackSelectorParameters(Context)} instead.
+ */
+ @Deprecated
+ public static final Parameters DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT =
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT;
+
+ /**
+ * @deprecated This instance does not have {@link Context} constraints. Use {@link
+ * #getDefaultTrackSelectorParameters(Context)} instead.
+ */
+ @Deprecated
+ public static final DefaultTrackSelector.Parameters DEFAULT_TRACK_SELECTOR_PARAMETERS =
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT;
+
+ /** Returns the default parameters used for track selection for downloading. */
+ public static DefaultTrackSelector.Parameters getDefaultTrackSelectorParameters(Context context) {
+ return Parameters.getDefaults(context)
+ .buildUpon()
+ .setForceHighestSupportedBitrate(true)
+ .build();
+ }
+
+ /** A callback to be notified when the {@link DownloadHelper} is prepared. */
+ public interface Callback {
+
+ /**
+ * Called when preparation completes.
+ *
+ * @param helper The reporting {@link DownloadHelper}.
+ */
+ void onPrepared(DownloadHelper helper);
+
+ /**
+ * Called when preparation fails.
+ *
+ * @param helper The reporting {@link DownloadHelper}.
+ * @param e The error.
+ */
+ void onPrepareError(DownloadHelper helper, IOException e);
+ }
+
+ /** Thrown at an attempt to download live content. */
+ public static class LiveContentUnsupportedException extends IOException {}
+
+ @Nullable
+ private static final Constructor<? extends MediaSourceFactory> DASH_FACTORY_CONSTRUCTOR =
+ getConstructor("com.google.android.exoplayer2.source.dash.DashMediaSource$Factory");
+
+ @Nullable
+ private static final Constructor<? extends MediaSourceFactory> SS_FACTORY_CONSTRUCTOR =
+ getConstructor("com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory");
+
+ @Nullable
+ private static final Constructor<? extends MediaSourceFactory> HLS_FACTORY_CONSTRUCTOR =
+ getConstructor("com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory");
+
+ /** @deprecated Use {@link #forProgressive(Context, Uri)} */
+ @Deprecated
+ @SuppressWarnings("deprecation")
+ public static DownloadHelper forProgressive(Uri uri) {
+ return forProgressive(uri, /* cacheKey= */ null);
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for progressive streams.
+ *
+ * @param context Any {@link Context}.
+ * @param uri A stream {@link Uri}.
+ * @return A {@link DownloadHelper} for progressive streams.
+ */
+ public static DownloadHelper forProgressive(Context context, Uri uri) {
+ return forProgressive(context, uri, /* cacheKey= */ null);
+ }
+
+ /** @deprecated Use {@link #forProgressive(Context, Uri, String)} */
+ @Deprecated
+ public static DownloadHelper forProgressive(Uri uri, @Nullable String cacheKey) {
+ return new DownloadHelper(
+ DownloadRequest.TYPE_PROGRESSIVE,
+ uri,
+ cacheKey,
+ /* mediaSource= */ null,
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT,
+ /* rendererCapabilities= */ new RendererCapabilities[0]);
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for progressive streams.
+ *
+ * @param context Any {@link Context}.
+ * @param uri A stream {@link Uri}.
+ * @param cacheKey An optional cache key.
+ * @return A {@link DownloadHelper} for progressive streams.
+ */
+ public static DownloadHelper forProgressive(Context context, Uri uri, @Nullable String cacheKey) {
+ return new DownloadHelper(
+ DownloadRequest.TYPE_PROGRESSIVE,
+ uri,
+ cacheKey,
+ /* mediaSource= */ null,
+ getDefaultTrackSelectorParameters(context),
+ /* rendererCapabilities= */ new RendererCapabilities[0]);
+ }
+
+ /** @deprecated Use {@link #forDash(Context, Uri, Factory, RenderersFactory)} */
+ @Deprecated
+ public static DownloadHelper forDash(
+ Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) {
+ return forDash(
+ uri,
+ dataSourceFactory,
+ renderersFactory,
+ /* drmSessionManager= */ null,
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT);
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for DASH streams.
+ *
+ * @param context Any {@link Context}.
+ * @param uri A manifest {@link Uri}.
+ * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest.
+ * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
+ * selected.
+ * @return A {@link DownloadHelper} for DASH streams.
+ * @throws IllegalStateException If the DASH module is missing.
+ */
+ public static DownloadHelper forDash(
+ Context context,
+ Uri uri,
+ DataSource.Factory dataSourceFactory,
+ RenderersFactory renderersFactory) {
+ return forDash(
+ uri,
+ dataSourceFactory,
+ renderersFactory,
+ /* drmSessionManager= */ null,
+ getDefaultTrackSelectorParameters(context));
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for DASH streams.
+ *
+ * @param uri A manifest {@link Uri}.
+ * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest.
+ * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
+ * selected.
+ * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which
+ * tracks can be selected.
+ * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for
+ * downloading.
+ * @return A {@link DownloadHelper} for DASH streams.
+ * @throws IllegalStateException If the DASH module is missing.
+ */
+ public static DownloadHelper forDash(
+ Uri uri,
+ DataSource.Factory dataSourceFactory,
+ RenderersFactory renderersFactory,
+ @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager,
+ DefaultTrackSelector.Parameters trackSelectorParameters) {
+ return new DownloadHelper(
+ DownloadRequest.TYPE_DASH,
+ uri,
+ /* cacheKey= */ null,
+ createMediaSourceInternal(
+ DASH_FACTORY_CONSTRUCTOR,
+ uri,
+ dataSourceFactory,
+ drmSessionManager,
+ /* streamKeys= */ null),
+ trackSelectorParameters,
+ Util.getRendererCapabilities(renderersFactory));
+ }
+
+ /** @deprecated Use {@link #forHls(Context, Uri, Factory, RenderersFactory)} */
+ @Deprecated
+ public static DownloadHelper forHls(
+ Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) {
+ return forHls(
+ uri,
+ dataSourceFactory,
+ renderersFactory,
+ /* drmSessionManager= */ null,
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT);
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for HLS streams.
+ *
+ * @param context Any {@link Context}.
+ * @param uri A playlist {@link Uri}.
+ * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist.
+ * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
+ * selected.
+ * @return A {@link DownloadHelper} for HLS streams.
+ * @throws IllegalStateException If the HLS module is missing.
+ */
+ public static DownloadHelper forHls(
+ Context context,
+ Uri uri,
+ DataSource.Factory dataSourceFactory,
+ RenderersFactory renderersFactory) {
+ return forHls(
+ uri,
+ dataSourceFactory,
+ renderersFactory,
+ /* drmSessionManager= */ null,
+ getDefaultTrackSelectorParameters(context));
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for HLS streams.
+ *
+ * @param uri A playlist {@link Uri}.
+ * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist.
+ * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
+ * selected.
+ * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which
+ * tracks can be selected.
+ * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for
+ * downloading.
+ * @return A {@link DownloadHelper} for HLS streams.
+ * @throws IllegalStateException If the HLS module is missing.
+ */
+ public static DownloadHelper forHls(
+ Uri uri,
+ DataSource.Factory dataSourceFactory,
+ RenderersFactory renderersFactory,
+ @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager,
+ DefaultTrackSelector.Parameters trackSelectorParameters) {
+ return new DownloadHelper(
+ DownloadRequest.TYPE_HLS,
+ uri,
+ /* cacheKey= */ null,
+ createMediaSourceInternal(
+ HLS_FACTORY_CONSTRUCTOR,
+ uri,
+ dataSourceFactory,
+ drmSessionManager,
+ /* streamKeys= */ null),
+ trackSelectorParameters,
+ Util.getRendererCapabilities(renderersFactory));
+ }
+
+ /** @deprecated Use {@link #forSmoothStreaming(Context, Uri, Factory, RenderersFactory)} */
+ @Deprecated
+ public static DownloadHelper forSmoothStreaming(
+ Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) {
+ return forSmoothStreaming(
+ uri,
+ dataSourceFactory,
+ renderersFactory,
+ /* drmSessionManager= */ null,
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT);
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for SmoothStreaming streams.
+ *
+ * @param context Any {@link Context}.
+ * @param uri A manifest {@link Uri}.
+ * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest.
+ * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
+ * selected.
+ * @return A {@link DownloadHelper} for SmoothStreaming streams.
+ * @throws IllegalStateException If the SmoothStreaming module is missing.
+ */
+ public static DownloadHelper forSmoothStreaming(
+ Context context,
+ Uri uri,
+ DataSource.Factory dataSourceFactory,
+ RenderersFactory renderersFactory) {
+ return forSmoothStreaming(
+ uri,
+ dataSourceFactory,
+ renderersFactory,
+ /* drmSessionManager= */ null,
+ getDefaultTrackSelectorParameters(context));
+ }
+
+ /**
+ * Creates a {@link DownloadHelper} for SmoothStreaming streams.
+ *
+ * @param uri A manifest {@link Uri}.
+ * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest.
+ * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are
+ * selected.
+ * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which
+ * tracks can be selected.
+ * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for
+ * downloading.
+ * @return A {@link DownloadHelper} for SmoothStreaming streams.
+ * @throws IllegalStateException If the SmoothStreaming module is missing.
+ */
+ public static DownloadHelper forSmoothStreaming(
+ Uri uri,
+ DataSource.Factory dataSourceFactory,
+ RenderersFactory renderersFactory,
+ @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager,
+ DefaultTrackSelector.Parameters trackSelectorParameters) {
+ return new DownloadHelper(
+ DownloadRequest.TYPE_SS,
+ uri,
+ /* cacheKey= */ null,
+ createMediaSourceInternal(
+ SS_FACTORY_CONSTRUCTOR,
+ uri,
+ dataSourceFactory,
+ drmSessionManager,
+ /* streamKeys= */ null),
+ trackSelectorParameters,
+ Util.getRendererCapabilities(renderersFactory));
+ }
+
+ /**
+ * Equivalent to {@link #createMediaSource(DownloadRequest, Factory, DrmSessionManager)
+ * createMediaSource(downloadRequest, dataSourceFactory, null)}.
+ */
+ public static MediaSource createMediaSource(
+ DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory) {
+ return createMediaSource(downloadRequest, dataSourceFactory, /* drmSessionManager= */ null);
+ }
+
+ /**
+ * Utility method to create a {@link MediaSource} that only exposes the tracks defined in {@code
+ * downloadRequest}.
+ *
+ * @param downloadRequest A {@link DownloadRequest}.
+ * @param dataSourceFactory A factory for {@link DataSource}s to read the media.
+ * @param drmSessionManager An optional {@link DrmSessionManager} to be passed to the {@link
+ * MediaSource}.
+ * @return A {@link MediaSource} that only exposes the tracks defined in {@code downloadRequest}.
+ */
+ public static MediaSource createMediaSource(
+ DownloadRequest downloadRequest,
+ DataSource.Factory dataSourceFactory,
+ @Nullable DrmSessionManager<?> drmSessionManager) {
+ @Nullable Constructor<? extends MediaSourceFactory> constructor;
+ switch (downloadRequest.type) {
+ case DownloadRequest.TYPE_DASH:
+ constructor = DASH_FACTORY_CONSTRUCTOR;
+ break;
+ case DownloadRequest.TYPE_SS:
+ constructor = SS_FACTORY_CONSTRUCTOR;
+ break;
+ case DownloadRequest.TYPE_HLS:
+ constructor = HLS_FACTORY_CONSTRUCTOR;
+ break;
+ case DownloadRequest.TYPE_PROGRESSIVE:
+ return new ProgressiveMediaSource.Factory(dataSourceFactory)
+ .setCustomCacheKey(downloadRequest.customCacheKey)
+ .createMediaSource(downloadRequest.uri);
+ default:
+ throw new IllegalStateException("Unsupported type: " + downloadRequest.type);
+ }
+ return createMediaSourceInternal(
+ constructor,
+ downloadRequest.uri,
+ dataSourceFactory,
+ drmSessionManager,
+ downloadRequest.streamKeys);
+ }
+
+ private final String downloadType;
+ private final Uri uri;
+ @Nullable private final String cacheKey;
+ @Nullable private final MediaSource mediaSource;
+ private final DefaultTrackSelector trackSelector;
+ private final RendererCapabilities[] rendererCapabilities;
+ private final SparseIntArray scratchSet;
+ private final Handler callbackHandler;
+ private final Timeline.Window window;
+
+ private boolean isPreparedWithMedia;
+ private @MonotonicNonNull Callback callback;
+ private @MonotonicNonNull MediaPreparer mediaPreparer;
+ private TrackGroupArray @MonotonicNonNull [] trackGroupArrays;
+ private MappedTrackInfo @MonotonicNonNull [] mappedTrackInfos;
+ private List<TrackSelection> @MonotonicNonNull [][] trackSelectionsByPeriodAndRenderer;
+ private List<TrackSelection> @MonotonicNonNull [][] immutableTrackSelectionsByPeriodAndRenderer;
+
+ /**
+ * Creates download helper.
+ *
+ * @param downloadType A download type. This value will be used as {@link DownloadRequest#type}.
+ * @param uri A {@link Uri}.
+ * @param cacheKey An optional cache key.
+ * @param mediaSource A {@link MediaSource} for which tracks are selected, or null if no track
+ * selection needs to be made.
+ * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for
+ * downloading.
+ * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which tracks
+ * are selected.
+ */
+ public DownloadHelper(
+ String downloadType,
+ Uri uri,
+ @Nullable String cacheKey,
+ @Nullable MediaSource mediaSource,
+ DefaultTrackSelector.Parameters trackSelectorParameters,
+ RendererCapabilities[] rendererCapabilities) {
+ this.downloadType = downloadType;
+ this.uri = uri;
+ this.cacheKey = cacheKey;
+ this.mediaSource = mediaSource;
+ this.trackSelector =
+ new DefaultTrackSelector(trackSelectorParameters, new DownloadTrackSelection.Factory());
+ this.rendererCapabilities = rendererCapabilities;
+ this.scratchSet = new SparseIntArray();
+ trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter());
+ callbackHandler = new Handler(Util.getLooper());
+ window = new Timeline.Window();
+ }
+
+ /**
+ * Initializes the helper for starting a download.
+ *
+ * @param callback A callback to be notified when preparation completes or fails.
+ * @throws IllegalStateException If the download helper has already been prepared.
+ */
+ public void prepare(Callback callback) {
+ Assertions.checkState(this.callback == null);
+ this.callback = callback;
+ if (mediaSource != null) {
+ mediaPreparer = new MediaPreparer(mediaSource, /* downloadHelper= */ this);
+ } else {
+ callbackHandler.post(() -> callback.onPrepared(this));
+ }
+ }
+
+ /** Releases the helper and all resources it is holding. */
+ public void release() {
+ if (mediaPreparer != null) {
+ mediaPreparer.release();
+ }
+ }
+
+ /**
+ * Returns the manifest, or null if no manifest is loaded. Must not be called until after
+ * preparation completes.
+ */
+ @Nullable
+ public Object getManifest() {
+ if (mediaSource == null) {
+ return null;
+ }
+ assertPreparedWithMedia();
+ return mediaPreparer.timeline.getWindowCount() > 0
+ ? mediaPreparer.timeline.getWindow(/* windowIndex= */ 0, window).manifest
+ : null;
+ }
+
+ /**
+ * Returns the number of periods for which media is available. Must not be called until after
+ * preparation completes.
+ */
+ public int getPeriodCount() {
+ if (mediaSource == null) {
+ return 0;
+ }
+ assertPreparedWithMedia();
+ return trackGroupArrays.length;
+ }
+
+ /**
+ * Returns the track groups for the given period. Must not be called until after preparation
+ * completes.
+ *
+ * <p>Use {@link #getMappedTrackInfo(int)} to get the track groups mapped to renderers.
+ *
+ * @param periodIndex The period index.
+ * @return The track groups for the period. May be {@link TrackGroupArray#EMPTY} for single stream
+ * content.
+ */
+ public TrackGroupArray getTrackGroups(int periodIndex) {
+ assertPreparedWithMedia();
+ return trackGroupArrays[periodIndex];
+ }
+
+ /**
+ * Returns the mapped track info for the given period. Must not be called until after preparation
+ * completes.
+ *
+ * @param periodIndex The period index.
+ * @return The {@link MappedTrackInfo} for the period.
+ */
+ public MappedTrackInfo getMappedTrackInfo(int periodIndex) {
+ assertPreparedWithMedia();
+ return mappedTrackInfos[periodIndex];
+ }
+
+ /**
+ * Returns all {@link TrackSelection track selections} for a period and renderer. Must not be
+ * called until after preparation completes.
+ *
+ * @param periodIndex The period index.
+ * @param rendererIndex The renderer index.
+ * @return A list of selected {@link TrackSelection track selections}.
+ */
+ public List<TrackSelection> getTrackSelections(int periodIndex, int rendererIndex) {
+ assertPreparedWithMedia();
+ return immutableTrackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex];
+ }
+
+ /**
+ * Clears the selection of tracks for a period. Must not be called until after preparation
+ * completes.
+ *
+ * @param periodIndex The period index for which track selections are cleared.
+ */
+ public void clearTrackSelections(int periodIndex) {
+ assertPreparedWithMedia();
+ for (int i = 0; i < rendererCapabilities.length; i++) {
+ trackSelectionsByPeriodAndRenderer[periodIndex][i].clear();
+ }
+ }
+
+ /**
+ * Replaces a selection of tracks to be downloaded. Must not be called until after preparation
+ * completes.
+ *
+ * @param periodIndex The period index for which the track selection is replaced.
+ * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new
+ * selection of tracks.
+ */
+ public void replaceTrackSelections(
+ int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) {
+ clearTrackSelections(periodIndex);
+ addTrackSelection(periodIndex, trackSelectorParameters);
+ }
+
+ /**
+ * Adds a selection of tracks to be downloaded. Must not be called until after preparation
+ * completes.
+ *
+ * @param periodIndex The period index this track selection is added for.
+ * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new
+ * selection of tracks.
+ */
+ public void addTrackSelection(
+ int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) {
+ assertPreparedWithMedia();
+ trackSelector.setParameters(trackSelectorParameters);
+ runTrackSelection(periodIndex);
+ }
+
+ /**
+ * Convenience method to add selections of tracks for all specified audio languages. If an audio
+ * track in one of the specified languages is not available, the default fallback audio track is
+ * used instead. Must not be called until after preparation completes.
+ *
+ * @param languages A list of audio languages for which tracks should be added to the download
+ * selection, as IETF BCP 47 conformant tags.
+ */
+ public void addAudioLanguagesToSelection(String... languages) {
+ assertPreparedWithMedia();
+ for (int periodIndex = 0; periodIndex < mappedTrackInfos.length; periodIndex++) {
+ DefaultTrackSelector.ParametersBuilder parametersBuilder =
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT.buildUpon();
+ MappedTrackInfo mappedTrackInfo = mappedTrackInfos[periodIndex];
+ int rendererCount = mappedTrackInfo.getRendererCount();
+ for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) {
+ if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) {
+ parametersBuilder.setRendererDisabled(rendererIndex, /* disabled= */ true);
+ }
+ }
+ for (String language : languages) {
+ parametersBuilder.setPreferredAudioLanguage(language);
+ addTrackSelection(periodIndex, parametersBuilder.build());
+ }
+ }
+ }
+
+ /**
+ * Convenience method to add selections of tracks for all specified text languages. Must not be
+ * called until after preparation completes.
+ *
+ * @param selectUndeterminedTextLanguage Whether a text track with undetermined language should be
+ * selected for downloading if no track with one of the specified {@code languages} is
+ * available.
+ * @param languages A list of text languages for which tracks should be added to the download
+ * selection, as IETF BCP 47 conformant tags.
+ */
+ public void addTextLanguagesToSelection(
+ boolean selectUndeterminedTextLanguage, String... languages) {
+ assertPreparedWithMedia();
+ for (int periodIndex = 0; periodIndex < mappedTrackInfos.length; periodIndex++) {
+ DefaultTrackSelector.ParametersBuilder parametersBuilder =
+ DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT.buildUpon();
+ MappedTrackInfo mappedTrackInfo = mappedTrackInfos[periodIndex];
+ int rendererCount = mappedTrackInfo.getRendererCount();
+ for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) {
+ if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_TEXT) {
+ parametersBuilder.setRendererDisabled(rendererIndex, /* disabled= */ true);
+ }
+ }
+ parametersBuilder.setSelectUndeterminedTextLanguage(selectUndeterminedTextLanguage);
+ for (String language : languages) {
+ parametersBuilder.setPreferredTextLanguage(language);
+ addTrackSelection(periodIndex, parametersBuilder.build());
+ }
+ }
+ }
+
+ /**
+ * Convenience method to add a selection of tracks to be downloaded for a single renderer. Must
+ * not be called until after preparation completes.
+ *
+ * @param periodIndex The period index the track selection is added for.
+ * @param rendererIndex The renderer index the track selection is added for.
+ * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new
+ * selection of tracks.
+ * @param overrides A list of {@link SelectionOverride SelectionOverrides} to apply to the {@code
+ * trackSelectorParameters}. If empty, {@code trackSelectorParameters} are used as they are.
+ */
+ public void addTrackSelectionForSingleRenderer(
+ int periodIndex,
+ int rendererIndex,
+ DefaultTrackSelector.Parameters trackSelectorParameters,
+ List<SelectionOverride> overrides) {
+ assertPreparedWithMedia();
+ DefaultTrackSelector.ParametersBuilder builder = trackSelectorParameters.buildUpon();
+ for (int i = 0; i < mappedTrackInfos[periodIndex].getRendererCount(); i++) {
+ builder.setRendererDisabled(/* rendererIndex= */ i, /* disabled= */ i != rendererIndex);
+ }
+ if (overrides.isEmpty()) {
+ addTrackSelection(periodIndex, builder.build());
+ } else {
+ TrackGroupArray trackGroupArray = mappedTrackInfos[periodIndex].getTrackGroups(rendererIndex);
+ for (int i = 0; i < overrides.size(); i++) {
+ builder.setSelectionOverride(rendererIndex, trackGroupArray, overrides.get(i));
+ addTrackSelection(periodIndex, builder.build());
+ }
+ }
+ }
+
+ /**
+ * Builds a {@link DownloadRequest} for downloading the selected tracks. Must not be called until
+ * after preparation completes. The uri of the {@link DownloadRequest} will be used as content id.
+ *
+ * @param data Application provided data to store in {@link DownloadRequest#data}.
+ * @return The built {@link DownloadRequest}.
+ */
+ public DownloadRequest getDownloadRequest(@Nullable byte[] data) {
+ return getDownloadRequest(uri.toString(), data);
+ }
+
+ /**
+ * Builds a {@link DownloadRequest} for downloading the selected tracks. Must not be called until
+ * after preparation completes.
+ *
+ * @param id The unique content id.
+ * @param data Application provided data to store in {@link DownloadRequest#data}.
+ * @return The built {@link DownloadRequest}.
+ */
+ public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) {
+ if (mediaSource == null) {
+ return new DownloadRequest(
+ id, downloadType, uri, /* streamKeys= */ Collections.emptyList(), cacheKey, data);
+ }
+ assertPreparedWithMedia();
+ List<StreamKey> streamKeys = new ArrayList<>();
+ List<TrackSelection> allSelections = new ArrayList<>();
+ int periodCount = trackSelectionsByPeriodAndRenderer.length;
+ for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) {
+ allSelections.clear();
+ int rendererCount = trackSelectionsByPeriodAndRenderer[periodIndex].length;
+ for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) {
+ allSelections.addAll(trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]);
+ }
+ streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections));
+ }
+ return new DownloadRequest(id, downloadType, uri, streamKeys, cacheKey, data);
+ }
+
+ // Initialization of array of Lists.
+ @SuppressWarnings("unchecked")
+ private void onMediaPrepared() {
+ Assertions.checkNotNull(mediaPreparer);
+ Assertions.checkNotNull(mediaPreparer.mediaPeriods);
+ Assertions.checkNotNull(mediaPreparer.timeline);
+ int periodCount = mediaPreparer.mediaPeriods.length;
+ int rendererCount = rendererCapabilities.length;
+ trackSelectionsByPeriodAndRenderer =
+ (List<TrackSelection>[][]) new List<?>[periodCount][rendererCount];
+ immutableTrackSelectionsByPeriodAndRenderer =
+ (List<TrackSelection>[][]) new List<?>[periodCount][rendererCount];
+ for (int i = 0; i < periodCount; i++) {
+ for (int j = 0; j < rendererCount; j++) {
+ trackSelectionsByPeriodAndRenderer[i][j] = new ArrayList<>();
+ immutableTrackSelectionsByPeriodAndRenderer[i][j] =
+ Collections.unmodifiableList(trackSelectionsByPeriodAndRenderer[i][j]);
+ }
+ }
+ trackGroupArrays = new TrackGroupArray[periodCount];
+ mappedTrackInfos = new MappedTrackInfo[periodCount];
+ for (int i = 0; i < periodCount; i++) {
+ trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups();
+ TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i);
+ trackSelector.onSelectionActivated(trackSelectorResult.info);
+ mappedTrackInfos[i] = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo());
+ }
+ setPreparedWithMedia();
+ Assertions.checkNotNull(callbackHandler)
+ .post(() -> Assertions.checkNotNull(callback).onPrepared(this));
+ }
+
+ private void onMediaPreparationFailed(IOException error) {
+ Assertions.checkNotNull(callbackHandler)
+ .post(() -> Assertions.checkNotNull(callback).onPrepareError(this, error));
+ }
+
+ @RequiresNonNull({
+ "trackGroupArrays",
+ "mappedTrackInfos",
+ "trackSelectionsByPeriodAndRenderer",
+ "immutableTrackSelectionsByPeriodAndRenderer",
+ "mediaPreparer",
+ "mediaPreparer.timeline",
+ "mediaPreparer.mediaPeriods"
+ })
+ private void setPreparedWithMedia() {
+ isPreparedWithMedia = true;
+ }
+
+ @EnsuresNonNull({
+ "trackGroupArrays",
+ "mappedTrackInfos",
+ "trackSelectionsByPeriodAndRenderer",
+ "immutableTrackSelectionsByPeriodAndRenderer",
+ "mediaPreparer",
+ "mediaPreparer.timeline",
+ "mediaPreparer.mediaPeriods"
+ })
+ @SuppressWarnings("nullness:contracts.postcondition.not.satisfied")
+ private void assertPreparedWithMedia() {
+ Assertions.checkState(isPreparedWithMedia);
+ }
+
+ /**
+ * Runs the track selection for a given period index with the current parameters. The selected
+ * tracks will be added to {@link #trackSelectionsByPeriodAndRenderer}.
+ */
+ // Intentional reference comparison of track group instances.
+ @SuppressWarnings("ReferenceEquality")
+ @RequiresNonNull({
+ "trackGroupArrays",
+ "trackSelectionsByPeriodAndRenderer",
+ "mediaPreparer",
+ "mediaPreparer.timeline"
+ })
+ private TrackSelectorResult runTrackSelection(int periodIndex) {
+ try {
+ TrackSelectorResult trackSelectorResult =
+ trackSelector.selectTracks(
+ rendererCapabilities,
+ trackGroupArrays[periodIndex],
+ new MediaPeriodId(mediaPreparer.timeline.getUidOfPeriod(periodIndex)),
+ mediaPreparer.timeline);
+ for (int i = 0; i < trackSelectorResult.length; i++) {
+ @Nullable TrackSelection newSelection = trackSelectorResult.selections.get(i);
+ if (newSelection == null) {
+ continue;
+ }
+ List<TrackSelection> existingSelectionList =
+ trackSelectionsByPeriodAndRenderer[periodIndex][i];
+ boolean mergedWithExistingSelection = false;
+ for (int j = 0; j < existingSelectionList.size(); j++) {
+ TrackSelection existingSelection = existingSelectionList.get(j);
+ if (existingSelection.getTrackGroup() == newSelection.getTrackGroup()) {
+ // Merge with existing selection.
+ scratchSet.clear();
+ for (int k = 0; k < existingSelection.length(); k++) {
+ scratchSet.put(existingSelection.getIndexInTrackGroup(k), 0);
+ }
+ for (int k = 0; k < newSelection.length(); k++) {
+ scratchSet.put(newSelection.getIndexInTrackGroup(k), 0);
+ }
+ int[] mergedTracks = new int[scratchSet.size()];
+ for (int k = 0; k < scratchSet.size(); k++) {
+ mergedTracks[k] = scratchSet.keyAt(k);
+ }
+ existingSelectionList.set(
+ j, new DownloadTrackSelection(existingSelection.getTrackGroup(), mergedTracks));
+ mergedWithExistingSelection = true;
+ break;
+ }
+ }
+ if (!mergedWithExistingSelection) {
+ existingSelectionList.add(newSelection);
+ }
+ }
+ return trackSelectorResult;
+ } catch (ExoPlaybackException e) {
+ // DefaultTrackSelector does not throw exceptions during track selection.
+ throw new UnsupportedOperationException(e);
+ }
+ }
+
+ @Nullable
+ private static Constructor<? extends MediaSourceFactory> getConstructor(String className) {
+ try {
+ // LINT.IfChange
+ Class<? extends MediaSourceFactory> factoryClazz =
+ Class.forName(className).asSubclass(MediaSourceFactory.class);
+ return factoryClazz.getConstructor(Factory.class);
+ // LINT.ThenChange(../../../../../../../../proguard-rules.txt)
+ } catch (ClassNotFoundException e) {
+ // Expected if the app was built without the respective module.
+ return null;
+ } catch (NoSuchMethodException e) {
+ // Something is wrong with the library or the proguard configuration.
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static MediaSource createMediaSourceInternal(
+ @Nullable Constructor<? extends MediaSourceFactory> constructor,
+ Uri uri,
+ Factory dataSourceFactory,
+ @Nullable DrmSessionManager<?> drmSessionManager,
+ @Nullable List<StreamKey> streamKeys) {
+ if (constructor == null) {
+ throw new IllegalStateException("Module missing to create media source.");
+ }
+ try {
+ MediaSourceFactory factory = constructor.newInstance(dataSourceFactory);
+ if (drmSessionManager != null) {
+ factory.setDrmSessionManager(drmSessionManager);
+ }
+ if (streamKeys != null) {
+ factory.setStreamKeys(streamKeys);
+ }
+ return Assertions.checkNotNull(factory.createMediaSource(uri));
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to instantiate media source.", e);
+ }
+ }
+
+ private static final class MediaPreparer
+ implements MediaSourceCaller, MediaPeriod.Callback, Handler.Callback {
+
+ private static final int MESSAGE_PREPARE_SOURCE = 0;
+ private static final int MESSAGE_CHECK_FOR_FAILURE = 1;
+ private static final int MESSAGE_CONTINUE_LOADING = 2;
+ private static final int MESSAGE_RELEASE = 3;
+
+ private static final int DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED = 0;
+ private static final int DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED = 1;
+
+ private final MediaSource mediaSource;
+ private final DownloadHelper downloadHelper;
+ private final Allocator allocator;
+ private final ArrayList<MediaPeriod> pendingMediaPeriods;
+ private final Handler downloadHelperHandler;
+ private final HandlerThread mediaSourceThread;
+ private final Handler mediaSourceHandler;
+
+ public @MonotonicNonNull Timeline timeline;
+ public MediaPeriod @MonotonicNonNull [] mediaPeriods;
+
+ private boolean released;
+
+ public MediaPreparer(MediaSource mediaSource, DownloadHelper downloadHelper) {
+ this.mediaSource = mediaSource;
+ this.downloadHelper = downloadHelper;
+ allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE);
+ pendingMediaPeriods = new ArrayList<>();
+ @SuppressWarnings("methodref.receiver.bound.invalid")
+ Handler downloadThreadHandler = Util.createHandler(this::handleDownloadHelperCallbackMessage);
+ this.downloadHelperHandler = downloadThreadHandler;
+ mediaSourceThread = new HandlerThread("DownloadHelper");
+ mediaSourceThread.start();
+ mediaSourceHandler = Util.createHandler(mediaSourceThread.getLooper(), /* callback= */ this);
+ mediaSourceHandler.sendEmptyMessage(MESSAGE_PREPARE_SOURCE);
+ }
+
+ public void release() {
+ if (released) {
+ return;
+ }
+ released = true;
+ mediaSourceHandler.sendEmptyMessage(MESSAGE_RELEASE);
+ }
+
+ // Handler.Callback
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MESSAGE_PREPARE_SOURCE:
+ mediaSource.prepareSource(/* caller= */ this, /* mediaTransferListener= */ null);
+ mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_FOR_FAILURE);
+ return true;
+ case MESSAGE_CHECK_FOR_FAILURE:
+ try {
+ if (mediaPeriods == null) {
+ mediaSource.maybeThrowSourceInfoRefreshError();
+ } else {
+ for (int i = 0; i < pendingMediaPeriods.size(); i++) {
+ pendingMediaPeriods.get(i).maybeThrowPrepareError();
+ }
+ }
+ mediaSourceHandler.sendEmptyMessageDelayed(
+ MESSAGE_CHECK_FOR_FAILURE, /* delayMillis= */ 100);
+ } catch (IOException e) {
+ downloadHelperHandler
+ .obtainMessage(DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED, /* obj= */ e)
+ .sendToTarget();
+ }
+ return true;
+ case MESSAGE_CONTINUE_LOADING:
+ MediaPeriod mediaPeriod = (MediaPeriod) msg.obj;
+ if (pendingMediaPeriods.contains(mediaPeriod)) {
+ mediaPeriod.continueLoading(/* positionUs= */ 0);
+ }
+ return true;
+ case MESSAGE_RELEASE:
+ if (mediaPeriods != null) {
+ for (MediaPeriod period : mediaPeriods) {
+ mediaSource.releasePeriod(period);
+ }
+ }
+ mediaSource.releaseSource(this);
+ mediaSourceHandler.removeCallbacksAndMessages(null);
+ mediaSourceThread.quit();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ // MediaSource.MediaSourceCaller implementation.
+
+ @Override
+ public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) {
+ if (this.timeline != null) {
+ // Ignore dynamic updates.
+ return;
+ }
+ if (timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).isLive) {
+ downloadHelperHandler
+ .obtainMessage(
+ DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED,
+ /* obj= */ new LiveContentUnsupportedException())
+ .sendToTarget();
+ return;
+ }
+ this.timeline = timeline;
+ mediaPeriods = new MediaPeriod[timeline.getPeriodCount()];
+ for (int i = 0; i < mediaPeriods.length; i++) {
+ MediaPeriod mediaPeriod =
+ mediaSource.createPeriod(
+ new MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ i)),
+ allocator,
+ /* startPositionUs= */ 0);
+ mediaPeriods[i] = mediaPeriod;
+ pendingMediaPeriods.add(mediaPeriod);
+ }
+ for (MediaPeriod mediaPeriod : mediaPeriods) {
+ mediaPeriod.prepare(/* callback= */ this, /* positionUs= */ 0);
+ }
+ }
+
+ // MediaPeriod.Callback implementation.
+
+ @Override
+ public void onPrepared(MediaPeriod mediaPeriod) {
+ pendingMediaPeriods.remove(mediaPeriod);
+ if (pendingMediaPeriods.isEmpty()) {
+ mediaSourceHandler.removeMessages(MESSAGE_CHECK_FOR_FAILURE);
+ downloadHelperHandler.sendEmptyMessage(DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED);
+ }
+ }
+
+ @Override
+ public void onContinueLoadingRequested(MediaPeriod mediaPeriod) {
+ if (pendingMediaPeriods.contains(mediaPeriod)) {
+ mediaSourceHandler.obtainMessage(MESSAGE_CONTINUE_LOADING, mediaPeriod).sendToTarget();
+ }
+ }
+
+ private boolean handleDownloadHelperCallbackMessage(Message msg) {
+ if (released) {
+ // Stale message.
+ return false;
+ }
+ switch (msg.what) {
+ case DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED:
+ downloadHelper.onMediaPrepared();
+ return true;
+ case DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED:
+ release();
+ downloadHelper.onMediaPreparationFailed((IOException) Util.castNonNull(msg.obj));
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+
+ private static final class DownloadTrackSelection extends BaseTrackSelection {
+
+ private static final class Factory implements TrackSelection.Factory {
+
+ @Override
+ public @NullableType TrackSelection[] createTrackSelections(
+ @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) {
+ @NullableType TrackSelection[] selections = new TrackSelection[definitions.length];
+ for (int i = 0; i < definitions.length; i++) {
+ selections[i] =
+ definitions[i] == null
+ ? null
+ : new DownloadTrackSelection(definitions[i].group, definitions[i].tracks);
+ }
+ return selections;
+ }
+ }
+
+ public DownloadTrackSelection(TrackGroup trackGroup, int[] tracks) {
+ super(trackGroup, tracks);
+ }
+
+ @Override
+ public int getSelectedIndex() {
+ return 0;
+ }
+
+ @Override
+ public int getSelectionReason() {
+ return C.SELECTION_REASON_UNKNOWN;
+ }
+
+ @Nullable
+ @Override
+ public Object getSelectionData() {
+ return null;
+ }
+
+ @Override
+ public void updateSelectedTrack(
+ long playbackPositionUs,
+ long bufferedDurationUs,
+ long availableDurationUs,
+ List<? extends MediaChunk> queue,
+ MediaChunkIterator[] mediaChunkIterators) {
+ // Do nothing.
+ }
+ }
+
+ private static final class DummyBandwidthMeter implements BandwidthMeter {
+
+ @Override
+ public long getBitrateEstimate() {
+ return 0;
+ }
+
+ @Nullable
+ @Override
+ public TransferListener getTransferListener() {
+ return null;
+ }
+
+ @Override
+ public void addEventListener(Handler eventHandler, EventListener eventListener) {
+ // Do nothing.
+ }
+
+ @Override
+ public void removeEventListener(EventListener eventListener) {
+ // Do nothing.
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadIndex.java
new file mode 100644
index 0000000000..5fbb3e7c0b
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadIndex.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2019 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 androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import java.io.IOException;
+
+/** An index of {@link Download Downloads}. */
+@WorkerThread
+public interface DownloadIndex {
+
+ /**
+ * Returns the {@link Download} with the given {@code id}, or null.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param id ID of a {@link Download}.
+ * @return The {@link Download} with the given {@code id}, or null if a download state with this
+ * id doesn't exist.
+ * @throws IOException If an error occurs reading the state.
+ */
+ @Nullable
+ Download getDownload(String id) throws IOException;
+
+ /**
+ * Returns a {@link DownloadCursor} to {@link Download}s with the given {@code states}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param states Returns only the {@link Download}s with this states. If empty, returns all.
+ * @return A cursor to {@link Download}s with the given {@code states}.
+ * @throws IOException If an error occurs reading the state.
+ */
+ DownloadCursor getDownloads(@Download.State int... states) throws IOException;
+}
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;
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadProgress.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadProgress.java
new file mode 100644
index 0000000000..177698ec1e
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadProgress.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2019 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 org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+
+/** Mutable {@link Download} progress. */
+public class DownloadProgress {
+
+ /** The number of bytes that have been downloaded. */
+ public long bytesDownloaded;
+
+ /** The percentage that has been downloaded, or {@link C#PERCENTAGE_UNSET} if unknown. */
+ public float percentDownloaded;
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadRequest.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadRequest.java
new file mode 100644
index 0000000000..31a441aa2d
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadRequest.java
@@ -0,0 +1,212 @@
+/*
+ * 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.util.Util.castNonNull;
+
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/** Defines content to be downloaded. */
+public final class DownloadRequest implements Parcelable {
+
+ /** Thrown when the encoded request data belongs to an unsupported request type. */
+ public static class UnsupportedRequestException extends IOException {}
+
+ /** Type for progressive downloads. */
+ public static final String TYPE_PROGRESSIVE = "progressive";
+ /** Type for DASH downloads. */
+ public static final String TYPE_DASH = "dash";
+ /** Type for HLS downloads. */
+ public static final String TYPE_HLS = "hls";
+ /** Type for SmoothStreaming downloads. */
+ public static final String TYPE_SS = "ss";
+
+ /** The unique content id. */
+ public final String id;
+ /** The type of the request. */
+ public final String type;
+ /** The uri being downloaded. */
+ public final Uri uri;
+ /** Stream keys to be downloaded. If empty, all streams will be downloaded. */
+ public final List<StreamKey> streamKeys;
+ /**
+ * Custom key for cache indexing, or null. Must be null for DASH, HLS and SmoothStreaming
+ * downloads.
+ */
+ @Nullable public final String customCacheKey;
+ /** Application defined data associated with the download. May be empty. */
+ public final byte[] data;
+
+ /**
+ * @param id See {@link #id}.
+ * @param type See {@link #type}.
+ * @param uri See {@link #uri}.
+ * @param streamKeys See {@link #streamKeys}.
+ * @param customCacheKey See {@link #customCacheKey}.
+ * @param data See {@link #data}.
+ */
+ public DownloadRequest(
+ String id,
+ String type,
+ Uri uri,
+ List<StreamKey> streamKeys,
+ @Nullable String customCacheKey,
+ @Nullable byte[] data) {
+ if (TYPE_DASH.equals(type) || TYPE_HLS.equals(type) || TYPE_SS.equals(type)) {
+ Assertions.checkArgument(
+ customCacheKey == null, "customCacheKey must be null for type: " + type);
+ }
+ this.id = id;
+ this.type = type;
+ this.uri = uri;
+ ArrayList<StreamKey> mutableKeys = new ArrayList<>(streamKeys);
+ Collections.sort(mutableKeys);
+ this.streamKeys = Collections.unmodifiableList(mutableKeys);
+ this.customCacheKey = customCacheKey;
+ this.data = data != null ? Arrays.copyOf(data, data.length) : Util.EMPTY_BYTE_ARRAY;
+ }
+
+ /* package */ DownloadRequest(Parcel in) {
+ id = castNonNull(in.readString());
+ type = castNonNull(in.readString());
+ uri = Uri.parse(castNonNull(in.readString()));
+ int streamKeyCount = in.readInt();
+ ArrayList<StreamKey> mutableStreamKeys = new ArrayList<>(streamKeyCount);
+ for (int i = 0; i < streamKeyCount; i++) {
+ mutableStreamKeys.add(in.readParcelable(StreamKey.class.getClassLoader()));
+ }
+ streamKeys = Collections.unmodifiableList(mutableStreamKeys);
+ customCacheKey = in.readString();
+ data = castNonNull(in.createByteArray());
+ }
+
+ /**
+ * Returns a copy with the specified ID.
+ *
+ * @param id The ID of the copy.
+ * @return The copy with the specified ID.
+ */
+ public DownloadRequest copyWithId(String id) {
+ return new DownloadRequest(id, type, uri, streamKeys, customCacheKey, data);
+ }
+
+ /**
+ * Returns the result of merging {@code newRequest} into this request. The requests must have the
+ * same {@link #id} and {@link #type}.
+ *
+ * <p>If the requests have different {@link #uri}, {@link #customCacheKey} and {@link #data}
+ * values, then those from the request being merged are included in the result.
+ *
+ * @param newRequest The request being merged.
+ * @return The merged result.
+ * @throws IllegalArgumentException If the requests do not have the same {@link #id} and {@link
+ * #type}.
+ */
+ public DownloadRequest copyWithMergedRequest(DownloadRequest newRequest) {
+ Assertions.checkArgument(id.equals(newRequest.id));
+ Assertions.checkArgument(type.equals(newRequest.type));
+ List<StreamKey> mergedKeys;
+ if (streamKeys.isEmpty() || newRequest.streamKeys.isEmpty()) {
+ // If either streamKeys is empty then all streams should be downloaded.
+ mergedKeys = Collections.emptyList();
+ } else {
+ mergedKeys = new ArrayList<>(streamKeys);
+ for (int i = 0; i < newRequest.streamKeys.size(); i++) {
+ StreamKey newKey = newRequest.streamKeys.get(i);
+ if (!mergedKeys.contains(newKey)) {
+ mergedKeys.add(newKey);
+ }
+ }
+ }
+ return new DownloadRequest(
+ id, type, newRequest.uri, mergedKeys, newRequest.customCacheKey, newRequest.data);
+ }
+
+ @Override
+ public String toString() {
+ return type + ":" + id;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (!(o instanceof DownloadRequest)) {
+ return false;
+ }
+ DownloadRequest that = (DownloadRequest) o;
+ return id.equals(that.id)
+ && type.equals(that.type)
+ && uri.equals(that.uri)
+ && streamKeys.equals(that.streamKeys)
+ && Util.areEqual(customCacheKey, that.customCacheKey)
+ && Arrays.equals(data, that.data);
+ }
+
+ @Override
+ public final int hashCode() {
+ int result = type.hashCode();
+ result = 31 * result + id.hashCode();
+ result = 31 * result + type.hashCode();
+ result = 31 * result + uri.hashCode();
+ result = 31 * result + streamKeys.hashCode();
+ result = 31 * result + (customCacheKey != null ? customCacheKey.hashCode() : 0);
+ result = 31 * result + Arrays.hashCode(data);
+ return result;
+ }
+
+ // Parcelable implementation.
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(id);
+ dest.writeString(type);
+ dest.writeString(uri.toString());
+ dest.writeInt(streamKeys.size());
+ for (int i = 0; i < streamKeys.size(); i++) {
+ dest.writeParcelable(streamKeys.get(i), /* parcelableFlags= */ 0);
+ }
+ dest.writeString(customCacheKey);
+ dest.writeByteArray(data);
+ }
+
+ public static final Parcelable.Creator<DownloadRequest> CREATOR =
+ new Parcelable.Creator<DownloadRequest>() {
+
+ @Override
+ public DownloadRequest createFromParcel(Parcel in) {
+ return new DownloadRequest(in);
+ }
+
+ @Override
+ public DownloadRequest[] newArray(int size) {
+ return new DownloadRequest[size];
+ }
+ };
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadService.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadService.java
new file mode 100644
index 0000000000..a2d7d82438
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadService.java
@@ -0,0 +1,1049 @@
+/*
+ * 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.STOP_REASON_NONE;
+
+import android.app.Notification;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.Requirements;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.Scheduler;
+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.NotificationUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.HashMap;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/** A {@link Service} for downloading media. */
+public abstract class DownloadService extends Service {
+
+ /**
+ * Starts a download service to resume any ongoing downloads. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_INIT =
+ "com.google.android.exoplayer.downloadService.action.INIT";
+
+ /** Like {@link #ACTION_INIT}, but with {@link #KEY_FOREGROUND} implicitly set to true. */
+ private static final String ACTION_RESTART =
+ "com.google.android.exoplayer.downloadService.action.RESTART";
+
+ /**
+ * Adds a new download. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_DOWNLOAD_REQUEST} - A {@link DownloadRequest} defining the download to be
+ * added.
+ * <li>{@link #KEY_STOP_REASON} - An initial stop reason for the download. If omitted {@link
+ * Download#STOP_REASON_NONE} is used.
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_ADD_DOWNLOAD =
+ "com.google.android.exoplayer.downloadService.action.ADD_DOWNLOAD";
+
+ /**
+ * Removes a download. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_CONTENT_ID} - The content id of a download to remove.
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_REMOVE_DOWNLOAD =
+ "com.google.android.exoplayer.downloadService.action.REMOVE_DOWNLOAD";
+
+ /**
+ * Removes all downloads. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_REMOVE_ALL_DOWNLOADS =
+ "com.google.android.exoplayer.downloadService.action.REMOVE_ALL_DOWNLOADS";
+
+ /**
+ * Resumes all downloads except those that have a non-zero {@link Download#stopReason}. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_RESUME_DOWNLOADS =
+ "com.google.android.exoplayer.downloadService.action.RESUME_DOWNLOADS";
+
+ /**
+ * Pauses all downloads. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_PAUSE_DOWNLOADS =
+ "com.google.android.exoplayer.downloadService.action.PAUSE_DOWNLOADS";
+
+ /**
+ * Sets the stop reason for one or all downloads. To clear the stop reason, pass {@link
+ * Download#STOP_REASON_NONE}. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_CONTENT_ID} - The content id of a single download to update with the stop
+ * reason. If omitted, all downloads will be updated.
+ * <li>{@link #KEY_STOP_REASON} - An application provided reason for stopping the download or
+ * downloads, or {@link Download#STOP_REASON_NONE} to clear the stop reason.
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_SET_STOP_REASON =
+ "com.google.android.exoplayer.downloadService.action.SET_STOP_REASON";
+
+ /**
+ * Sets the requirements that need to be met for downloads to progress. Extras:
+ *
+ * <ul>
+ * <li>{@link #KEY_REQUIREMENTS} - A {@link Requirements}.
+ * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}.
+ * </ul>
+ */
+ public static final String ACTION_SET_REQUIREMENTS =
+ "com.google.android.exoplayer.downloadService.action.SET_REQUIREMENTS";
+
+ /** Key for the {@link DownloadRequest} in {@link #ACTION_ADD_DOWNLOAD} intents. */
+ public static final String KEY_DOWNLOAD_REQUEST = "download_request";
+
+ /**
+ * Key for the {@link String} content id in {@link #ACTION_SET_STOP_REASON} and {@link
+ * #ACTION_REMOVE_DOWNLOAD} intents.
+ */
+ public static final String KEY_CONTENT_ID = "content_id";
+
+ /**
+ * Key for the integer stop reason in {@link #ACTION_SET_STOP_REASON} and {@link
+ * #ACTION_ADD_DOWNLOAD} intents.
+ */
+ public static final String KEY_STOP_REASON = "stop_reason";
+
+ /** Key for the {@link Requirements} in {@link #ACTION_SET_REQUIREMENTS} intents. */
+ public static final String KEY_REQUIREMENTS = "requirements";
+
+ /**
+ * Key for a boolean extra that can be set on any intent to indicate whether the service was
+ * started in the foreground. If set, the service is guaranteed to call {@link
+ * #startForeground(int, Notification)}.
+ */
+ public static final String KEY_FOREGROUND = "foreground";
+
+ /** Invalid foreground notification id that can be used to run the service in the background. */
+ public static final int FOREGROUND_NOTIFICATION_ID_NONE = 0;
+
+ /** Default foreground notification update interval in milliseconds. */
+ public static final long DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL = 1000;
+
+ private static final String TAG = "DownloadService";
+
+ // Keep a DownloadManagerHelper for each DownloadService as long as the process is running. The
+ // helper is needed to restart the DownloadService when there's no scheduler. Even when there is a
+ // scheduler, the DownloadManagerHelper is typically able to restart the DownloadService faster.
+ private static final HashMap<Class<? extends DownloadService>, DownloadManagerHelper>
+ downloadManagerHelpers = new HashMap<>();
+
+ @Nullable private final ForegroundNotificationUpdater foregroundNotificationUpdater;
+ @Nullable private final String channelId;
+ @StringRes private final int channelNameResourceId;
+ @StringRes private final int channelDescriptionResourceId;
+
+ @MonotonicNonNull private DownloadManager downloadManager;
+ private int lastStartId;
+ private boolean startedInForeground;
+ private boolean taskRemoved;
+ private boolean isStopped;
+ private boolean isDestroyed;
+
+ /**
+ * Creates a DownloadService.
+ *
+ * <p>If {@code foregroundNotificationId} is {@link #FOREGROUND_NOTIFICATION_ID_NONE} then the
+ * service will only ever run in the background. No foreground notification will be displayed and
+ * {@link #getScheduler()} will not be called.
+ *
+ * <p>If {@code foregroundNotificationId} is not {@link #FOREGROUND_NOTIFICATION_ID_NONE} then the
+ * service will run in the foreground. The foreground notification will be updated at least as
+ * often as the interval specified by {@link #DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL}.
+ *
+ * @param foregroundNotificationId The notification id for the foreground notification, or {@link
+ * #FOREGROUND_NOTIFICATION_ID_NONE} if the service should only ever run in the background.
+ */
+ protected DownloadService(int foregroundNotificationId) {
+ this(foregroundNotificationId, DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL);
+ }
+
+ /**
+ * Creates a DownloadService.
+ *
+ * @param foregroundNotificationId The notification id for the foreground notification, or {@link
+ * #FOREGROUND_NOTIFICATION_ID_NONE} if the service should only ever run in the background.
+ * @param foregroundNotificationUpdateInterval The maximum interval between updates to the
+ * foreground notification, in milliseconds. Ignored if {@code foregroundNotificationId} is
+ * {@link #FOREGROUND_NOTIFICATION_ID_NONE}.
+ */
+ protected DownloadService(
+ int foregroundNotificationId, long foregroundNotificationUpdateInterval) {
+ this(
+ foregroundNotificationId,
+ foregroundNotificationUpdateInterval,
+ /* channelId= */ null,
+ /* channelNameResourceId= */ 0,
+ /* channelDescriptionResourceId= */ 0);
+ }
+
+ /** @deprecated Use {@link #DownloadService(int, long, String, int, int)}. */
+ @Deprecated
+ protected DownloadService(
+ int foregroundNotificationId,
+ long foregroundNotificationUpdateInterval,
+ @Nullable String channelId,
+ @StringRes int channelNameResourceId) {
+ this(
+ foregroundNotificationId,
+ foregroundNotificationUpdateInterval,
+ channelId,
+ channelNameResourceId,
+ /* channelDescriptionResourceId= */ 0);
+ }
+
+ /**
+ * Creates a DownloadService.
+ *
+ * @param foregroundNotificationId The notification id for the foreground notification, or {@link
+ * #FOREGROUND_NOTIFICATION_ID_NONE} if the service should only ever run in the background.
+ * @param foregroundNotificationUpdateInterval The maximum interval between updates to the
+ * foreground notification, in milliseconds. Ignored if {@code foregroundNotificationId} is
+ * {@link #FOREGROUND_NOTIFICATION_ID_NONE}.
+ * @param channelId An id for a low priority notification channel to create, or {@code null} if
+ * the app will take care of creating a notification channel if needed. If specified, must be
+ * unique per package. The value may be truncated if it's too long. Ignored if {@code
+ * foregroundNotificationId} is {@link #FOREGROUND_NOTIFICATION_ID_NONE}.
+ * @param channelNameResourceId A string resource identifier for the user visible name of the
+ * notification channel. The recommended maximum length is 40 characters. The value may be
+ * truncated if it's too long. Ignored if {@code channelId} is null or if {@code
+ * foregroundNotificationId} is {@link #FOREGROUND_NOTIFICATION_ID_NONE}.
+ * @param channelDescriptionResourceId A string resource identifier for the user visible
+ * description of the notification channel, or 0 if no description is provided. The
+ * recommended maximum length is 300 characters. The value may be truncated if it is too long.
+ * Ignored if {@code channelId} is null or if {@code foregroundNotificationId} is {@link
+ * #FOREGROUND_NOTIFICATION_ID_NONE}.
+ */
+ protected DownloadService(
+ int foregroundNotificationId,
+ long foregroundNotificationUpdateInterval,
+ @Nullable String channelId,
+ @StringRes int channelNameResourceId,
+ @StringRes int channelDescriptionResourceId) {
+ if (foregroundNotificationId == FOREGROUND_NOTIFICATION_ID_NONE) {
+ this.foregroundNotificationUpdater = null;
+ this.channelId = null;
+ this.channelNameResourceId = 0;
+ this.channelDescriptionResourceId = 0;
+ } else {
+ this.foregroundNotificationUpdater =
+ new ForegroundNotificationUpdater(
+ foregroundNotificationId, foregroundNotificationUpdateInterval);
+ this.channelId = channelId;
+ this.channelNameResourceId = channelNameResourceId;
+ this.channelDescriptionResourceId = channelDescriptionResourceId;
+ }
+ }
+
+ /**
+ * Builds an {@link Intent} for adding a new download.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param downloadRequest The request to be executed.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildAddDownloadIntent(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ DownloadRequest downloadRequest,
+ boolean foreground) {
+ return buildAddDownloadIntent(context, clazz, downloadRequest, STOP_REASON_NONE, foreground);
+ }
+
+ /**
+ * Builds an {@link Intent} for adding a new download.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param downloadRequest The request to be executed.
+ * @param stopReason An initial stop reason for the download, or {@link Download#STOP_REASON_NONE}
+ * if the download should be started.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildAddDownloadIntent(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ DownloadRequest downloadRequest,
+ int stopReason,
+ boolean foreground) {
+ return getIntent(context, clazz, ACTION_ADD_DOWNLOAD, foreground)
+ .putExtra(KEY_DOWNLOAD_REQUEST, downloadRequest)
+ .putExtra(KEY_STOP_REASON, stopReason);
+ }
+
+ /**
+ * Builds an {@link Intent} for removing the download with the {@code id}.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param id The content id.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildRemoveDownloadIntent(
+ Context context, Class<? extends DownloadService> clazz, String id, boolean foreground) {
+ return getIntent(context, clazz, ACTION_REMOVE_DOWNLOAD, foreground)
+ .putExtra(KEY_CONTENT_ID, id);
+ }
+
+ /**
+ * Builds an {@link Intent} for removing all downloads.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildRemoveAllDownloadsIntent(
+ Context context, Class<? extends DownloadService> clazz, boolean foreground) {
+ return getIntent(context, clazz, ACTION_REMOVE_ALL_DOWNLOADS, foreground);
+ }
+
+ /**
+ * Builds an {@link Intent} for resuming all downloads.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildResumeDownloadsIntent(
+ Context context, Class<? extends DownloadService> clazz, boolean foreground) {
+ return getIntent(context, clazz, ACTION_RESUME_DOWNLOADS, foreground);
+ }
+
+ /**
+ * Builds an {@link Intent} to pause all downloads.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildPauseDownloadsIntent(
+ Context context, Class<? extends DownloadService> clazz, boolean foreground) {
+ return getIntent(context, clazz, ACTION_PAUSE_DOWNLOADS, foreground);
+ }
+
+ /**
+ * Builds an {@link Intent} for setting the stop reason for one or all downloads. To clear the
+ * stop reason, pass {@link Download#STOP_REASON_NONE}.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param id The content id, or {@code null} to set the stop reason for all downloads.
+ * @param stopReason An application defined stop reason.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildSetStopReasonIntent(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ @Nullable String id,
+ int stopReason,
+ boolean foreground) {
+ return getIntent(context, clazz, ACTION_SET_STOP_REASON, foreground)
+ .putExtra(KEY_CONTENT_ID, id)
+ .putExtra(KEY_STOP_REASON, stopReason);
+ }
+
+ /**
+ * Builds an {@link Intent} for setting the requirements that need to be met for downloads to
+ * progress.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service being targeted by the intent.
+ * @param requirements A {@link Requirements}.
+ * @param foreground Whether this intent will be used to start the service in the foreground.
+ * @return The created intent.
+ */
+ public static Intent buildSetRequirementsIntent(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ Requirements requirements,
+ boolean foreground) {
+ return getIntent(context, clazz, ACTION_SET_REQUIREMENTS, foreground)
+ .putExtra(KEY_REQUIREMENTS, requirements);
+ }
+
+ /**
+ * Starts the service if not started already and adds a new download.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param downloadRequest The request to be executed.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendAddDownload(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ DownloadRequest downloadRequest,
+ boolean foreground) {
+ Intent intent = buildAddDownloadIntent(context, clazz, downloadRequest, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts the service if not started already and adds a new download.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param downloadRequest The request to be executed.
+ * @param stopReason An initial stop reason for the download, or {@link Download#STOP_REASON_NONE}
+ * if the download should be started.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendAddDownload(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ DownloadRequest downloadRequest,
+ int stopReason,
+ boolean foreground) {
+ Intent intent = buildAddDownloadIntent(context, clazz, downloadRequest, stopReason, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts the service if not started already and removes a download.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param id The content id.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendRemoveDownload(
+ Context context, Class<? extends DownloadService> clazz, String id, boolean foreground) {
+ Intent intent = buildRemoveDownloadIntent(context, clazz, id, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts the service if not started already and removes all downloads.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendRemoveAllDownloads(
+ Context context, Class<? extends DownloadService> clazz, boolean foreground) {
+ Intent intent = buildRemoveAllDownloadsIntent(context, clazz, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts the service if not started already and resumes all downloads.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendResumeDownloads(
+ Context context, Class<? extends DownloadService> clazz, boolean foreground) {
+ Intent intent = buildResumeDownloadsIntent(context, clazz, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts the service if not started already and pauses all downloads.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendPauseDownloads(
+ Context context, Class<? extends DownloadService> clazz, boolean foreground) {
+ Intent intent = buildPauseDownloadsIntent(context, clazz, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts the service if not started already and sets the stop reason for one or all downloads. To
+ * clear stop reason, pass {@link Download#STOP_REASON_NONE}.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param id The content id, or {@code null} to set the stop reason for all downloads.
+ * @param stopReason An application defined stop reason.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendSetStopReason(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ @Nullable String id,
+ int stopReason,
+ boolean foreground) {
+ Intent intent = buildSetStopReasonIntent(context, clazz, id, stopReason, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts the service if not started already and sets the requirements that need to be met for
+ * downloads to progress.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @param requirements A {@link Requirements}.
+ * @param foreground Whether the service is started in the foreground.
+ */
+ public static void sendSetRequirements(
+ Context context,
+ Class<? extends DownloadService> clazz,
+ Requirements requirements,
+ boolean foreground) {
+ Intent intent = buildSetRequirementsIntent(context, clazz, requirements, foreground);
+ startService(context, intent, foreground);
+ }
+
+ /**
+ * Starts a download service to resume any ongoing downloads.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @see #startForeground(Context, Class)
+ */
+ public static void start(Context context, Class<? extends DownloadService> clazz) {
+ context.startService(getIntent(context, clazz, ACTION_INIT));
+ }
+
+ /**
+ * Starts the service in the foreground without adding a new download request. If there are any
+ * not finished downloads and the requirements are met, the service resumes downloading. Otherwise
+ * it stops immediately.
+ *
+ * @param context A {@link Context}.
+ * @param clazz The concrete download service to be started.
+ * @see #start(Context, Class)
+ */
+ public static void startForeground(Context context, Class<? extends DownloadService> clazz) {
+ Intent intent = getIntent(context, clazz, ACTION_INIT, true);
+ Util.startForegroundService(context, intent);
+ }
+
+ @Override
+ public void onCreate() {
+ if (channelId != null) {
+ NotificationUtil.createNotificationChannel(
+ this,
+ channelId,
+ channelNameResourceId,
+ channelDescriptionResourceId,
+ NotificationUtil.IMPORTANCE_LOW);
+ }
+ Class<? extends DownloadService> clazz = getClass();
+ @Nullable DownloadManagerHelper downloadManagerHelper = downloadManagerHelpers.get(clazz);
+ if (downloadManagerHelper == null) {
+ boolean foregroundAllowed = foregroundNotificationUpdater != null;
+ @Nullable Scheduler scheduler = foregroundAllowed ? getScheduler() : null;
+ downloadManager = getDownloadManager();
+ downloadManager.resumeDownloads();
+ downloadManagerHelper =
+ new DownloadManagerHelper(
+ getApplicationContext(), downloadManager, foregroundAllowed, scheduler, clazz);
+ downloadManagerHelpers.put(clazz, downloadManagerHelper);
+ } else {
+ downloadManager = downloadManagerHelper.downloadManager;
+ }
+ downloadManagerHelper.attachService(this);
+ }
+
+ @Override
+ public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
+ lastStartId = startId;
+ taskRemoved = false;
+ @Nullable String intentAction = null;
+ @Nullable String contentId = null;
+ if (intent != null) {
+ intentAction = intent.getAction();
+ contentId = intent.getStringExtra(KEY_CONTENT_ID);
+ startedInForeground |=
+ intent.getBooleanExtra(KEY_FOREGROUND, false) || ACTION_RESTART.equals(intentAction);
+ }
+ // intentAction is null if the service is restarted or no action is specified.
+ if (intentAction == null) {
+ intentAction = ACTION_INIT;
+ }
+ DownloadManager downloadManager = Assertions.checkNotNull(this.downloadManager);
+ switch (intentAction) {
+ case ACTION_INIT:
+ case ACTION_RESTART:
+ // Do nothing.
+ break;
+ case ACTION_ADD_DOWNLOAD:
+ @Nullable
+ DownloadRequest downloadRequest =
+ Assertions.checkNotNull(intent).getParcelableExtra(KEY_DOWNLOAD_REQUEST);
+ if (downloadRequest == null) {
+ Log.e(TAG, "Ignored ADD_DOWNLOAD: Missing " + KEY_DOWNLOAD_REQUEST + " extra");
+ } else {
+ int stopReason = intent.getIntExtra(KEY_STOP_REASON, Download.STOP_REASON_NONE);
+ downloadManager.addDownload(downloadRequest, stopReason);
+ }
+ break;
+ case ACTION_REMOVE_DOWNLOAD:
+ if (contentId == null) {
+ Log.e(TAG, "Ignored REMOVE_DOWNLOAD: Missing " + KEY_CONTENT_ID + " extra");
+ } else {
+ downloadManager.removeDownload(contentId);
+ }
+ break;
+ case ACTION_REMOVE_ALL_DOWNLOADS:
+ downloadManager.removeAllDownloads();
+ break;
+ case ACTION_RESUME_DOWNLOADS:
+ downloadManager.resumeDownloads();
+ break;
+ case ACTION_PAUSE_DOWNLOADS:
+ downloadManager.pauseDownloads();
+ break;
+ case ACTION_SET_STOP_REASON:
+ if (!Assertions.checkNotNull(intent).hasExtra(KEY_STOP_REASON)) {
+ Log.e(TAG, "Ignored SET_STOP_REASON: Missing " + KEY_STOP_REASON + " extra");
+ } else {
+ int stopReason = intent.getIntExtra(KEY_STOP_REASON, /* defaultValue= */ 0);
+ downloadManager.setStopReason(contentId, stopReason);
+ }
+ break;
+ case ACTION_SET_REQUIREMENTS:
+ @Nullable
+ Requirements requirements =
+ Assertions.checkNotNull(intent).getParcelableExtra(KEY_REQUIREMENTS);
+ if (requirements == null) {
+ Log.e(TAG, "Ignored SET_REQUIREMENTS: Missing " + KEY_REQUIREMENTS + " extra");
+ } else {
+ downloadManager.setRequirements(requirements);
+ }
+ break;
+ default:
+ Log.e(TAG, "Ignored unrecognized action: " + intentAction);
+ break;
+ }
+
+ if (Util.SDK_INT >= 26 && startedInForeground && foregroundNotificationUpdater != null) {
+ // From API level 26, services started in the foreground are required to show a notification.
+ foregroundNotificationUpdater.showNotificationIfNotAlready();
+ }
+
+ isStopped = false;
+ if (downloadManager.isIdle()) {
+ stop();
+ }
+ return START_STICKY;
+ }
+
+ @Override
+ public void onTaskRemoved(Intent rootIntent) {
+ taskRemoved = true;
+ }
+
+ @Override
+ public void onDestroy() {
+ isDestroyed = true;
+ DownloadManagerHelper downloadManagerHelper =
+ Assertions.checkNotNull(downloadManagerHelpers.get(getClass()));
+ downloadManagerHelper.detachService(this);
+ if (foregroundNotificationUpdater != null) {
+ foregroundNotificationUpdater.stopPeriodicUpdates();
+ }
+ }
+
+ /**
+ * Throws {@link UnsupportedOperationException} because this service is not designed to be bound.
+ */
+ @Nullable
+ @Override
+ public final IBinder onBind(Intent intent) {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Returns a {@link DownloadManager} to be used to downloaded content. Called only once in the
+ * life cycle of the process.
+ */
+ protected abstract DownloadManager getDownloadManager();
+
+ /**
+ * Returns a {@link Scheduler} to restart the service when requirements allowing downloads to take
+ * place are met. If {@code null}, the service will only be restarted if the process is still in
+ * memory when the requirements are met.
+ *
+ * <p>This method is not called for services whose {@code foregroundNotificationId} is set to
+ * {@link #FOREGROUND_NOTIFICATION_ID_NONE}. Such services will only be restarted if the process
+ * is still in memory and considered non-idle, meaning that it's either in the foreground or was
+ * backgrounded within the last few minutes.
+ */
+ @Nullable
+ protected abstract Scheduler getScheduler();
+
+ /**
+ * Returns a notification to be displayed when this service running in the foreground.
+ *
+ * <p>Download services that do not wish to run in the foreground should be created by setting the
+ * {@code foregroundNotificationId} constructor argument to {@link
+ * #FOREGROUND_NOTIFICATION_ID_NONE}. This method is not called for such services, meaning it can
+ * be implemented to throw {@link UnsupportedOperationException}.
+ *
+ * @param downloads The current downloads.
+ * @return The foreground notification to display.
+ */
+ protected abstract Notification getForegroundNotification(List<Download> downloads);
+
+ /**
+ * Invalidates the current foreground notification and causes {@link
+ * #getForegroundNotification(List)} to be invoked again if the service isn't stopped.
+ */
+ protected final void invalidateForegroundNotification() {
+ if (foregroundNotificationUpdater != null && !isDestroyed) {
+ foregroundNotificationUpdater.invalidate();
+ }
+ }
+
+ /**
+ * @deprecated Some state change events may not be delivered to this method. Instead, use {@link
+ * DownloadManager#addListener(DownloadManager.Listener)} to register a listener directly to
+ * the {@link DownloadManager} that you return through {@link #getDownloadManager()}.
+ */
+ @Deprecated
+ protected void onDownloadChanged(Download download) {
+ // Do nothing.
+ }
+
+ /**
+ * @deprecated Some download removal events may not be delivered to this method. Instead, use
+ * {@link DownloadManager#addListener(DownloadManager.Listener)} to register a listener
+ * directly to the {@link DownloadManager} that you return through {@link
+ * #getDownloadManager()}.
+ */
+ @Deprecated
+ protected void onDownloadRemoved(Download download) {
+ // Do nothing.
+ }
+
+ /**
+ * Called after the service is created, once the downloads are known.
+ *
+ * @param downloads The current downloads.
+ */
+ private void notifyDownloads(List<Download> downloads) {
+ if (foregroundNotificationUpdater != null) {
+ for (int i = 0; i < downloads.size(); i++) {
+ if (needsStartedService(downloads.get(i).state)) {
+ foregroundNotificationUpdater.startPeriodicUpdates();
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Called when the state of a download changes.
+ *
+ * @param download The state of the download.
+ */
+ @SuppressWarnings("deprecation")
+ private void notifyDownloadChanged(Download download) {
+ onDownloadChanged(download);
+ if (foregroundNotificationUpdater != null) {
+ if (needsStartedService(download.state)) {
+ foregroundNotificationUpdater.startPeriodicUpdates();
+ } else {
+ foregroundNotificationUpdater.invalidate();
+ }
+ }
+ }
+
+ /**
+ * Called when a download is removed.
+ *
+ * @param download The last state of the download before it was removed.
+ */
+ @SuppressWarnings("deprecation")
+ private void notifyDownloadRemoved(Download download) {
+ onDownloadRemoved(download);
+ if (foregroundNotificationUpdater != null) {
+ foregroundNotificationUpdater.invalidate();
+ }
+ }
+
+ /** Returns whether the service is stopped. */
+ private boolean isStopped() {
+ return isStopped;
+ }
+
+ private void stop() {
+ if (foregroundNotificationUpdater != null) {
+ foregroundNotificationUpdater.stopPeriodicUpdates();
+ }
+ if (Util.SDK_INT < 28 && taskRemoved) { // See [Internal: b/74248644].
+ stopSelf();
+ isStopped = true;
+ } else {
+ isStopped |= stopSelfResult(lastStartId);
+ }
+ }
+
+ private static boolean needsStartedService(@Download.State int state) {
+ return state == Download.STATE_DOWNLOADING
+ || state == Download.STATE_REMOVING
+ || state == Download.STATE_RESTARTING;
+ }
+
+ private static Intent getIntent(
+ Context context, Class<? extends DownloadService> clazz, String action, boolean foreground) {
+ return getIntent(context, clazz, action).putExtra(KEY_FOREGROUND, foreground);
+ }
+
+ private static Intent getIntent(
+ Context context, Class<? extends DownloadService> clazz, String action) {
+ return new Intent(context, clazz).setAction(action);
+ }
+
+ private static void startService(Context context, Intent intent, boolean foreground) {
+ if (foreground) {
+ Util.startForegroundService(context, intent);
+ } else {
+ context.startService(intent);
+ }
+ }
+
+ private final class ForegroundNotificationUpdater {
+
+ private final int notificationId;
+ private final long updateInterval;
+ private final Handler handler;
+
+ private boolean periodicUpdatesStarted;
+ private boolean notificationDisplayed;
+
+ public ForegroundNotificationUpdater(int notificationId, long updateInterval) {
+ this.notificationId = notificationId;
+ this.updateInterval = updateInterval;
+ this.handler = new Handler(Looper.getMainLooper());
+ }
+
+ public void startPeriodicUpdates() {
+ periodicUpdatesStarted = true;
+ update();
+ }
+
+ public void stopPeriodicUpdates() {
+ periodicUpdatesStarted = false;
+ handler.removeCallbacksAndMessages(null);
+ }
+
+ public void showNotificationIfNotAlready() {
+ if (!notificationDisplayed) {
+ update();
+ }
+ }
+
+ public void invalidate() {
+ if (notificationDisplayed) {
+ update();
+ }
+ }
+
+ private void update() {
+ List<Download> downloads = Assertions.checkNotNull(downloadManager).getCurrentDownloads();
+ startForeground(notificationId, getForegroundNotification(downloads));
+ notificationDisplayed = true;
+ if (periodicUpdatesStarted) {
+ handler.removeCallbacksAndMessages(null);
+ handler.postDelayed(this::update, updateInterval);
+ }
+ }
+ }
+
+ private static final class DownloadManagerHelper implements DownloadManager.Listener {
+
+ private final Context context;
+ private final DownloadManager downloadManager;
+ private final boolean foregroundAllowed;
+ @Nullable private final Scheduler scheduler;
+ private final Class<? extends DownloadService> serviceClass;
+ @Nullable private DownloadService downloadService;
+
+ private DownloadManagerHelper(
+ Context context,
+ DownloadManager downloadManager,
+ boolean foregroundAllowed,
+ @Nullable Scheduler scheduler,
+ Class<? extends DownloadService> serviceClass) {
+ this.context = context;
+ this.downloadManager = downloadManager;
+ this.foregroundAllowed = foregroundAllowed;
+ this.scheduler = scheduler;
+ this.serviceClass = serviceClass;
+ downloadManager.addListener(this);
+ updateScheduler();
+ }
+
+ public void attachService(DownloadService downloadService) {
+ Assertions.checkState(this.downloadService == null);
+ this.downloadService = downloadService;
+ if (downloadManager.isInitialized()) {
+ // The call to DownloadService.notifyDownloads is posted to avoid it being called directly
+ // from DownloadService.onCreate. This is a good idea because it may in turn call
+ // DownloadService.getForegroundNotification, and concrete subclass implementations may
+ // not anticipate the possibility of this method being called before their onCreate
+ // implementation has finished executing.
+ new Handler()
+ .postAtFrontOfQueue(
+ () -> downloadService.notifyDownloads(downloadManager.getCurrentDownloads()));
+ }
+ }
+
+ public void detachService(DownloadService downloadService) {
+ Assertions.checkState(this.downloadService == downloadService);
+ this.downloadService = null;
+ if (scheduler != null && !downloadManager.isWaitingForRequirements()) {
+ scheduler.cancel();
+ }
+ }
+
+ // DownloadManager.Listener implementation.
+
+ @Override
+ public void onInitialized(DownloadManager downloadManager) {
+ if (downloadService != null) {
+ downloadService.notifyDownloads(downloadManager.getCurrentDownloads());
+ }
+ }
+
+ @Override
+ public void onDownloadChanged(DownloadManager downloadManager, Download download) {
+ if (downloadService != null) {
+ downloadService.notifyDownloadChanged(download);
+ }
+ if (serviceMayNeedRestart() && needsStartedService(download.state)) {
+ // This shouldn't happen unless (a) application code is changing the downloads by calling
+ // the DownloadManager directly rather than sending actions through the service, or (b) if
+ // the service is background only and a previous attempt to start it was prevented. Try and
+ // restart the service to robust against such cases.
+ Log.w(TAG, "DownloadService wasn't running. Restarting.");
+ restartService();
+ }
+ }
+
+ @Override
+ public void onDownloadRemoved(DownloadManager downloadManager, Download download) {
+ if (downloadService != null) {
+ downloadService.notifyDownloadRemoved(download);
+ }
+ }
+
+ @Override
+ public final void onIdle(DownloadManager downloadManager) {
+ if (downloadService != null) {
+ downloadService.stop();
+ }
+ }
+
+ @Override
+ public void onWaitingForRequirementsChanged(
+ DownloadManager downloadManager, boolean waitingForRequirements) {
+ if (!waitingForRequirements
+ && !downloadManager.getDownloadsPaused()
+ && serviceMayNeedRestart()) {
+ // We're no longer waiting for requirements and downloads aren't paused, meaning the manager
+ // will be able to resume downloads that are currently queued. If there exist queued
+ // downloads then we should ensure the service is started.
+ List<Download> downloads = downloadManager.getCurrentDownloads();
+ for (int i = 0; i < downloads.size(); i++) {
+ if (downloads.get(i).state == Download.STATE_QUEUED) {
+ restartService();
+ break;
+ }
+ }
+ }
+ updateScheduler();
+ }
+
+ // Internal methods.
+
+ private boolean serviceMayNeedRestart() {
+ return downloadService == null || downloadService.isStopped();
+ }
+
+ private void restartService() {
+ if (foregroundAllowed) {
+ Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_RESTART);
+ Util.startForegroundService(context, intent);
+ } else {
+ // The service is background only. Use ACTION_INIT rather than ACTION_RESTART because
+ // ACTION_RESTART is handled as though KEY_FOREGROUND is set to true.
+ try {
+ Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT);
+ context.startService(intent);
+ } catch (IllegalArgumentException e) {
+ // The process is classed as idle by the platform. Starting a background service is not
+ // allowed in this state.
+ Log.w(TAG, "Failed to restart DownloadService (process is idle).");
+ }
+ }
+ }
+
+ private void updateScheduler() {
+ if (scheduler == null) {
+ return;
+ }
+ if (downloadManager.isWaitingForRequirements()) {
+ String servicePackage = context.getPackageName();
+ Requirements requirements = downloadManager.getRequirements();
+ boolean success = scheduler.schedule(requirements, servicePackage, ACTION_RESTART);
+ if (!success) {
+ Log.e(TAG, "Scheduling downloads failed.");
+ }
+ } else {
+ scheduler.cancel();
+ }
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Downloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Downloader.java
new file mode 100644
index 0000000000..894d908e72
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Downloader.java
@@ -0,0 +1,60 @@
+/*
+ * 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 androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import java.io.IOException;
+
+/** Downloads and removes a piece of content. */
+public interface Downloader {
+
+ /** Receives progress updates during download operations. */
+ interface ProgressListener {
+
+ /**
+ * Called when progress is made during a download operation.
+ *
+ * @param contentLength The length of the content in bytes, or {@link C#LENGTH_UNSET} if
+ * unknown.
+ * @param bytesDownloaded The number of bytes that have been downloaded.
+ * @param percentDownloaded The percentage of the content that has been downloaded, or {@link
+ * C#PERCENTAGE_UNSET}.
+ */
+ void onProgress(long contentLength, long bytesDownloaded, float percentDownloaded);
+ }
+
+ /**
+ * Downloads the content.
+ *
+ * @param progressListener A listener to receive progress updates, or {@code null}.
+ * @throws DownloadException Thrown if the content cannot be downloaded.
+ * @throws InterruptedException If the thread has been interrupted.
+ * @throws IOException Thrown when there is an io error while downloading.
+ */
+ void download(@Nullable ProgressListener progressListener)
+ throws InterruptedException, IOException;
+
+ /** Cancels the download operation and prevents future download operations from running. */
+ void cancel();
+
+ /**
+ * Removes the content.
+ *
+ * @throws InterruptedException Thrown if the thread was interrupted.
+ */
+ void remove() throws InterruptedException;
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java
new file mode 100644
index 0000000000..5b2f579868
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java
@@ -0,0 +1,170 @@
+/*
+ * 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 androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DummyDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.FileDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.PriorityDataSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSink;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSinkFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheKeyFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager;
+
+/** A helper class that holds necessary parameters for {@link Downloader} construction. */
+public final class DownloaderConstructorHelper {
+
+ private final Cache cache;
+ @Nullable private final CacheKeyFactory cacheKeyFactory;
+ @Nullable private final PriorityTaskManager priorityTaskManager;
+ private final CacheDataSourceFactory onlineCacheDataSourceFactory;
+ private final CacheDataSourceFactory offlineCacheDataSourceFactory;
+
+ /**
+ * @param cache Cache instance to be used to store downloaded data.
+ * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for
+ * downloading data.
+ */
+ public DownloaderConstructorHelper(Cache cache, DataSource.Factory upstreamFactory) {
+ this(
+ cache,
+ upstreamFactory,
+ /* cacheReadDataSourceFactory= */ null,
+ /* cacheWriteDataSinkFactory= */ null,
+ /* priorityTaskManager= */ null);
+ }
+
+ /**
+ * @param cache Cache instance to be used to store downloaded data.
+ * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for
+ * downloading data.
+ * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s
+ * for reading data from the cache. If null then a {@link FileDataSource.Factory} will be
+ * used.
+ * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s
+ * for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used.
+ * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null,
+ * downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst
+ * downloading.
+ */
+ public DownloaderConstructorHelper(
+ Cache cache,
+ DataSource.Factory upstreamFactory,
+ @Nullable DataSource.Factory cacheReadDataSourceFactory,
+ @Nullable DataSink.Factory cacheWriteDataSinkFactory,
+ @Nullable PriorityTaskManager priorityTaskManager) {
+ this(
+ cache,
+ upstreamFactory,
+ cacheReadDataSourceFactory,
+ cacheWriteDataSinkFactory,
+ priorityTaskManager,
+ /* cacheKeyFactory= */ null);
+ }
+
+ /**
+ * @param cache Cache instance to be used to store downloaded data.
+ * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for
+ * downloading data.
+ * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s
+ * for reading data from the cache. If null then a {@link FileDataSource.Factory} will be
+ * used.
+ * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s
+ * for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used.
+ * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null,
+ * downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst
+ * downloading.
+ * @param cacheKeyFactory An optional factory for cache keys.
+ */
+ public DownloaderConstructorHelper(
+ Cache cache,
+ DataSource.Factory upstreamFactory,
+ @Nullable DataSource.Factory cacheReadDataSourceFactory,
+ @Nullable DataSink.Factory cacheWriteDataSinkFactory,
+ @Nullable PriorityTaskManager priorityTaskManager,
+ @Nullable CacheKeyFactory cacheKeyFactory) {
+ if (priorityTaskManager != null) {
+ upstreamFactory =
+ new PriorityDataSourceFactory(upstreamFactory, priorityTaskManager, C.PRIORITY_DOWNLOAD);
+ }
+ DataSource.Factory readDataSourceFactory =
+ cacheReadDataSourceFactory != null
+ ? cacheReadDataSourceFactory
+ : new FileDataSource.Factory();
+ if (cacheWriteDataSinkFactory == null) {
+ cacheWriteDataSinkFactory =
+ new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE);
+ }
+ onlineCacheDataSourceFactory =
+ new CacheDataSourceFactory(
+ cache,
+ upstreamFactory,
+ readDataSourceFactory,
+ cacheWriteDataSinkFactory,
+ CacheDataSource.FLAG_BLOCK_ON_CACHE,
+ /* eventListener= */ null,
+ cacheKeyFactory);
+ offlineCacheDataSourceFactory =
+ new CacheDataSourceFactory(
+ cache,
+ DummyDataSource.FACTORY,
+ readDataSourceFactory,
+ null,
+ CacheDataSource.FLAG_BLOCK_ON_CACHE,
+ /* eventListener= */ null,
+ cacheKeyFactory);
+ this.cache = cache;
+ this.priorityTaskManager = priorityTaskManager;
+ this.cacheKeyFactory = cacheKeyFactory;
+ }
+
+ /** Returns the {@link Cache} instance. */
+ public Cache getCache() {
+ return cache;
+ }
+
+ /** Returns the {@link CacheKeyFactory}. */
+ public CacheKeyFactory getCacheKeyFactory() {
+ return cacheKeyFactory != null ? cacheKeyFactory : CacheUtil.DEFAULT_CACHE_KEY_FACTORY;
+ }
+
+ /** Returns a {@link PriorityTaskManager} instance. */
+ public PriorityTaskManager getPriorityTaskManager() {
+ // Return a dummy PriorityTaskManager if none is provided. Create a new PriorityTaskManager
+ // each time so clients don't affect each other over the dummy PriorityTaskManager instance.
+ return priorityTaskManager != null ? priorityTaskManager : new PriorityTaskManager();
+ }
+
+ /** Returns a new {@link CacheDataSource} instance. */
+ public CacheDataSource createCacheDataSource() {
+ return onlineCacheDataSourceFactory.createDataSource();
+ }
+
+ /**
+ * Returns a new {@link CacheDataSource} instance which accesses cache read-only and throws an
+ * exception on cache miss.
+ */
+ public CacheDataSource createOfflineCacheDataSource() {
+ return offlineCacheDataSourceFactory.createDataSource();
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderFactory.java
new file mode 100644
index 0000000000..944f55f161
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderFactory.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2018 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;
+
+/** Creates {@link Downloader Downloaders} for given {@link DownloadRequest DownloadRequests}. */
+public interface DownloaderFactory {
+
+ /**
+ * Creates a {@link Downloader} to perform the given {@link DownloadRequest}.
+ *
+ * @param action The action.
+ * @return The downloader.
+ */
+ Downloader createDownloader(DownloadRequest action);
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilterableManifest.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilterableManifest.java
new file mode 100644
index 0000000000..1bd32f7d45
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilterableManifest.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2018 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 java.util.List;
+
+/**
+ * A manifest that can generate copies of itself including only the streams specified by the given
+ * keys.
+ *
+ * @param <T> The manifest type.
+ */
+public interface FilterableManifest<T> {
+
+ /**
+ * Returns a copy of the manifest including only the streams specified by the given keys. If the
+ * manifest is unchanged then the instance may return itself.
+ *
+ * @param streamKeys A non-empty list of stream keys.
+ * @return The filtered manifest.
+ */
+ T copy(List<StreamKey> streamKeys);
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilteringManifestParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilteringManifestParser.java
new file mode 100644
index 0000000000..a34d749039
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilteringManifestParser.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2018 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 android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable.Parser;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * A manifest parser that includes only the streams identified by the given stream keys.
+ *
+ * @param <T> The {@link FilterableManifest} type.
+ */
+public final class FilteringManifestParser<T extends FilterableManifest<T>> implements Parser<T> {
+
+ private final Parser<? extends T> parser;
+ @Nullable private final List<StreamKey> streamKeys;
+
+ /**
+ * @param parser A parser for the manifest that will be filtered.
+ * @param streamKeys The stream keys. If null or empty then filtering will not occur.
+ */
+ public FilteringManifestParser(Parser<? extends T> parser, @Nullable List<StreamKey> streamKeys) {
+ this.parser = parser;
+ this.streamKeys = streamKeys;
+ }
+
+ @Override
+ public T parse(Uri uri, InputStream inputStream) throws IOException {
+ T manifest = parser.parse(uri, inputStream);
+ return streamKeys == null || streamKeys.isEmpty() ? manifest : manifest.copy(streamKeys);
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ProgressiveDownloader.java
new file mode 100644
index 0000000000..7437dab5ca
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ProgressiveDownloader.java
@@ -0,0 +1,120 @@
+/*
+ * 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 android.net.Uri;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheKeyFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A downloader for progressive media streams.
+ *
+ * <p>The downloader attempts to download the entire media bytes referenced by a {@link Uri} into a
+ * cache as defined by {@link DownloaderConstructorHelper}. Callers can use the constructor to
+ * specify a custom cache key for the downloaded bytes.
+ *
+ * <p>The downloader will avoid downloading already-downloaded media bytes.
+ */
+public final class ProgressiveDownloader implements Downloader {
+
+ private static final int BUFFER_SIZE_BYTES = 128 * 1024;
+
+ private final DataSpec dataSpec;
+ private final Cache cache;
+ private final CacheDataSource dataSource;
+ private final CacheKeyFactory cacheKeyFactory;
+ private final PriorityTaskManager priorityTaskManager;
+ private final AtomicBoolean isCanceled;
+
+ /**
+ * @param uri Uri of the data to be downloaded.
+ * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache
+ * indexing. May be null.
+ * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+ */
+ public ProgressiveDownloader(
+ Uri uri, @Nullable String customCacheKey, DownloaderConstructorHelper constructorHelper) {
+ this.dataSpec =
+ new DataSpec(
+ uri,
+ /* absoluteStreamPosition= */ 0,
+ C.LENGTH_UNSET,
+ customCacheKey,
+ /* flags= */ DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION);
+ this.cache = constructorHelper.getCache();
+ this.dataSource = constructorHelper.createCacheDataSource();
+ this.cacheKeyFactory = constructorHelper.getCacheKeyFactory();
+ this.priorityTaskManager = constructorHelper.getPriorityTaskManager();
+ isCanceled = new AtomicBoolean();
+ }
+
+ @Override
+ public void download(@Nullable ProgressListener progressListener)
+ throws InterruptedException, IOException {
+ priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
+ try {
+ CacheUtil.cache(
+ dataSpec,
+ cache,
+ cacheKeyFactory,
+ dataSource,
+ new byte[BUFFER_SIZE_BYTES],
+ priorityTaskManager,
+ C.PRIORITY_DOWNLOAD,
+ progressListener == null ? null : new ProgressForwarder(progressListener),
+ isCanceled,
+ /* enableEOFException= */ true);
+ } finally {
+ priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
+ }
+ }
+
+ @Override
+ public void cancel() {
+ isCanceled.set(true);
+ }
+
+ @Override
+ public void remove() {
+ CacheUtil.remove(dataSpec, cache, cacheKeyFactory);
+ }
+
+ private static final class ProgressForwarder implements CacheUtil.ProgressListener {
+
+ private final ProgressListener progessListener;
+
+ public ProgressForwarder(ProgressListener progressListener) {
+ this.progessListener = progressListener;
+ }
+
+ @Override
+ public void onProgress(long contentLength, long bytesCached, long newBytesCached) {
+ float percentDownloaded =
+ contentLength == C.LENGTH_UNSET || contentLength == 0
+ ? C.PERCENTAGE_UNSET
+ : ((bytesCached * 100f) / contentLength);
+ progessListener.onProgress(contentLength, bytesCached, percentDownloaded);
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/SegmentDownloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/SegmentDownloader.java
new file mode 100644
index 0000000000..92947b9bc9
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/SegmentDownloader.java
@@ -0,0 +1,279 @@
+/*
+ * 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 android.net.Uri;
+import android.util.Pair;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheKeyFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager;
+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.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Base class for multi segment stream downloaders.
+ *
+ * @param <M> The type of the manifest object.
+ */
+public abstract class SegmentDownloader<M extends FilterableManifest<M>> implements Downloader {
+
+ /** Smallest unit of content to be downloaded. */
+ protected static class Segment implements Comparable<Segment> {
+
+ /** The start time of the segment in microseconds. */
+ public final long startTimeUs;
+
+ /** The {@link DataSpec} of the segment. */
+ public final DataSpec dataSpec;
+
+ /** Constructs a Segment. */
+ public Segment(long startTimeUs, DataSpec dataSpec) {
+ this.startTimeUs = startTimeUs;
+ this.dataSpec = dataSpec;
+ }
+
+ @Override
+ public int compareTo(Segment other) {
+ return Util.compareLong(startTimeUs, other.startTimeUs);
+ }
+ }
+
+ private static final int BUFFER_SIZE_BYTES = 128 * 1024;
+
+ private final DataSpec manifestDataSpec;
+ private final Cache cache;
+ private final CacheDataSource dataSource;
+ private final CacheDataSource offlineDataSource;
+ private final CacheKeyFactory cacheKeyFactory;
+ private final PriorityTaskManager priorityTaskManager;
+ private final ArrayList<StreamKey> streamKeys;
+ private final AtomicBoolean isCanceled;
+
+ /**
+ * @param manifestUri The {@link Uri} of the manifest to be downloaded.
+ * @param streamKeys Keys defining which streams in the manifest should be selected for download.
+ * If empty, all streams are downloaded.
+ * @param constructorHelper A {@link DownloaderConstructorHelper} instance.
+ */
+ public SegmentDownloader(
+ Uri manifestUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) {
+ this.manifestDataSpec = getCompressibleDataSpec(manifestUri);
+ this.streamKeys = new ArrayList<>(streamKeys);
+ this.cache = constructorHelper.getCache();
+ this.dataSource = constructorHelper.createCacheDataSource();
+ this.offlineDataSource = constructorHelper.createOfflineCacheDataSource();
+ this.cacheKeyFactory = constructorHelper.getCacheKeyFactory();
+ this.priorityTaskManager = constructorHelper.getPriorityTaskManager();
+ isCanceled = new AtomicBoolean();
+ }
+
+ /**
+ * Downloads the selected streams in the media. If multiple streams are selected, they are
+ * downloaded in sync with one another.
+ *
+ * @throws IOException Thrown when there is an error downloading.
+ * @throws InterruptedException If the thread has been interrupted.
+ */
+ @Override
+ public final void download(@Nullable ProgressListener progressListener)
+ throws IOException, InterruptedException {
+ priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
+ try {
+ // Get the manifest and all of the segments.
+ M manifest = getManifest(dataSource, manifestDataSpec);
+ if (!streamKeys.isEmpty()) {
+ manifest = manifest.copy(streamKeys);
+ }
+ List<Segment> segments = getSegments(dataSource, manifest, /* allowIncompleteList= */ false);
+
+ // Scan the segments, removing any that are fully downloaded.
+ int totalSegments = segments.size();
+ int segmentsDownloaded = 0;
+ long contentLength = 0;
+ long bytesDownloaded = 0;
+ for (int i = segments.size() - 1; i >= 0; i--) {
+ Segment segment = segments.get(i);
+ Pair<Long, Long> segmentLengthAndBytesDownloaded =
+ CacheUtil.getCached(segment.dataSpec, cache, cacheKeyFactory);
+ long segmentLength = segmentLengthAndBytesDownloaded.first;
+ long segmentBytesDownloaded = segmentLengthAndBytesDownloaded.second;
+ bytesDownloaded += segmentBytesDownloaded;
+ if (segmentLength != C.LENGTH_UNSET) {
+ if (segmentLength == segmentBytesDownloaded) {
+ // The segment is fully downloaded.
+ segmentsDownloaded++;
+ segments.remove(i);
+ }
+ if (contentLength != C.LENGTH_UNSET) {
+ contentLength += segmentLength;
+ }
+ } else {
+ contentLength = C.LENGTH_UNSET;
+ }
+ }
+ Collections.sort(segments);
+
+ // Download the segments.
+ @Nullable ProgressNotifier progressNotifier = null;
+ if (progressListener != null) {
+ progressNotifier =
+ new ProgressNotifier(
+ progressListener,
+ contentLength,
+ totalSegments,
+ bytesDownloaded,
+ segmentsDownloaded);
+ }
+ byte[] buffer = new byte[BUFFER_SIZE_BYTES];
+ for (int i = 0; i < segments.size(); i++) {
+ CacheUtil.cache(
+ segments.get(i).dataSpec,
+ cache,
+ cacheKeyFactory,
+ dataSource,
+ buffer,
+ priorityTaskManager,
+ C.PRIORITY_DOWNLOAD,
+ progressNotifier,
+ isCanceled,
+ true);
+ if (progressNotifier != null) {
+ progressNotifier.onSegmentDownloaded();
+ }
+ }
+ } finally {
+ priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
+ }
+ }
+
+ @Override
+ public void cancel() {
+ isCanceled.set(true);
+ }
+
+ @Override
+ public final void remove() throws InterruptedException {
+ try {
+ M manifest = getManifest(offlineDataSource, manifestDataSpec);
+ List<Segment> segments = getSegments(offlineDataSource, manifest, true);
+ for (int i = 0; i < segments.size(); i++) {
+ removeDataSpec(segments.get(i).dataSpec);
+ }
+ } catch (IOException e) {
+ // Ignore exceptions when removing.
+ } finally {
+ // Always attempt to remove the manifest.
+ removeDataSpec(manifestDataSpec);
+ }
+ }
+
+ // Internal methods.
+
+ /**
+ * Loads and parses the manifest.
+ *
+ * @param dataSource The {@link DataSource} through which to load.
+ * @param dataSpec The manifest {@link DataSpec}.
+ * @return The manifest.
+ * @throws IOException If an error occurs reading data.
+ */
+ protected abstract M getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException;
+
+ /**
+ * Returns a list of all downloadable {@link Segment}s for a given manifest.
+ *
+ * @param dataSource The {@link DataSource} through which to load any required data.
+ * @param manifest The manifest containing the segments.
+ * @param allowIncompleteList Whether to continue in the case that a load error prevents all
+ * segments from being listed. If true then a partial segment list will be returned. If false
+ * an {@link IOException} will be thrown.
+ * @return The list of downloadable {@link Segment}s.
+ * @throws InterruptedException Thrown if the thread was interrupted.
+ * @throws IOException Thrown if {@code allowPartialIndex} is false and a load error occurs, or if
+ * the media is not in a form that allows for its segments to be listed.
+ */
+ protected abstract List<Segment> getSegments(
+ DataSource dataSource, M manifest, boolean allowIncompleteList)
+ throws InterruptedException, IOException;
+
+ private void removeDataSpec(DataSpec dataSpec) {
+ CacheUtil.remove(dataSpec, cache, cacheKeyFactory);
+ }
+
+ protected static DataSpec getCompressibleDataSpec(Uri uri) {
+ return new DataSpec(
+ uri,
+ /* absoluteStreamPosition= */ 0,
+ /* length= */ C.LENGTH_UNSET,
+ /* key= */ null,
+ /* flags= */ DataSpec.FLAG_ALLOW_GZIP);
+ }
+
+ private static final class ProgressNotifier implements CacheUtil.ProgressListener {
+
+ private final ProgressListener progressListener;
+
+ private final long contentLength;
+ private final int totalSegments;
+
+ private long bytesDownloaded;
+ private int segmentsDownloaded;
+
+ public ProgressNotifier(
+ ProgressListener progressListener,
+ long contentLength,
+ int totalSegments,
+ long bytesDownloaded,
+ int segmentsDownloaded) {
+ this.progressListener = progressListener;
+ this.contentLength = contentLength;
+ this.totalSegments = totalSegments;
+ this.bytesDownloaded = bytesDownloaded;
+ this.segmentsDownloaded = segmentsDownloaded;
+ }
+
+ @Override
+ public void onProgress(long requestLength, long bytesCached, long newBytesCached) {
+ bytesDownloaded += newBytesCached;
+ progressListener.onProgress(contentLength, bytesDownloaded, getPercentDownloaded());
+ }
+
+ public void onSegmentDownloaded() {
+ segmentsDownloaded++;
+ progressListener.onProgress(contentLength, bytesDownloaded, getPercentDownloaded());
+ }
+
+ private float getPercentDownloaded() {
+ if (contentLength != C.LENGTH_UNSET && contentLength != 0) {
+ return (bytesDownloaded * 100f) / contentLength;
+ } else if (totalSegments != 0) {
+ return (segmentsDownloaded * 100f) / totalSegments;
+ } else {
+ return C.PERCENTAGE_UNSET;
+ }
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/StreamKey.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/StreamKey.java
new file mode 100644
index 0000000000..acbcc9afa4
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/StreamKey.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2018 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 android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.Nullable;
+
+/**
+ * A key for a subset of media which can be separately loaded (a "stream").
+ *
+ * <p>The stream key consists of a period index, a group index within the period and a track index
+ * within the group. The interpretation of these indices depends on the type of media for which the
+ * stream key is used.
+ */
+public final class StreamKey implements Comparable<StreamKey>, Parcelable {
+
+ /** The period index. */
+ public final int periodIndex;
+ /** The group index. */
+ public final int groupIndex;
+ /** The track index. */
+ public final int trackIndex;
+
+ /**
+ * @param groupIndex The group index.
+ * @param trackIndex The track index.
+ */
+ public StreamKey(int groupIndex, int trackIndex) {
+ this(0, groupIndex, trackIndex);
+ }
+
+ /**
+ * @param periodIndex The period index.
+ * @param groupIndex The group index.
+ * @param trackIndex The track index.
+ */
+ public StreamKey(int periodIndex, int groupIndex, int trackIndex) {
+ this.periodIndex = periodIndex;
+ this.groupIndex = groupIndex;
+ this.trackIndex = trackIndex;
+ }
+
+ /* package */ StreamKey(Parcel in) {
+ periodIndex = in.readInt();
+ groupIndex = in.readInt();
+ trackIndex = in.readInt();
+ }
+
+ @Override
+ public String toString() {
+ return periodIndex + "." + groupIndex + "." + trackIndex;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ StreamKey that = (StreamKey) o;
+ return periodIndex == that.periodIndex
+ && groupIndex == that.groupIndex
+ && trackIndex == that.trackIndex;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = periodIndex;
+ result = 31 * result + groupIndex;
+ result = 31 * result + trackIndex;
+ return result;
+ }
+
+ // Comparable implementation.
+
+ @Override
+ public int compareTo(StreamKey o) {
+ int result = periodIndex - o.periodIndex;
+ if (result == 0) {
+ result = groupIndex - o.groupIndex;
+ if (result == 0) {
+ result = trackIndex - o.trackIndex;
+ }
+ }
+ return result;
+ }
+
+ // Parcelable implementation.
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(periodIndex);
+ dest.writeInt(groupIndex);
+ dest.writeInt(trackIndex);
+ }
+
+ public static final Parcelable.Creator<StreamKey> CREATOR =
+ new Parcelable.Creator<StreamKey>() {
+
+ @Override
+ public StreamKey createFromParcel(Parcel in) {
+ return new StreamKey(in);
+ }
+
+ @Override
+ public StreamKey[] newArray(int size) {
+ return new StreamKey[size];
+ }
+ };
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/WritableDownloadIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/WritableDownloadIndex.java
new file mode 100644
index 0000000000..f57619f0c4
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/WritableDownloadIndex.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2019 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 androidx.annotation.WorkerThread;
+import java.io.IOException;
+
+/** A writable index of {@link Download Downloads}. */
+@WorkerThread
+public interface WritableDownloadIndex extends DownloadIndex {
+
+ /**
+ * Adds or replaces a {@link Download}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param download The {@link Download} to be added.
+ * @throws IOException If an error occurs setting the state.
+ */
+ void putDownload(Download download) throws IOException;
+
+ /**
+ * Removes the download with the given ID. Does nothing if a download with the given ID does not
+ * exist.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param id The ID of the download to remove.
+ * @throws IOException If an error occurs removing the state.
+ */
+ void removeDownload(String id) throws IOException;
+
+ /**
+ * Sets all {@link Download#STATE_DOWNLOADING} states to {@link Download#STATE_QUEUED}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @throws IOException If an error occurs updating the state.
+ */
+ void setDownloadingStatesToQueued() throws IOException;
+
+ /**
+ * Sets all states to {@link Download#STATE_REMOVING}.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @throws IOException If an error occurs updating the state.
+ */
+ void setStatesToRemoving() throws IOException;
+
+ /**
+ * Sets the stop reason of the downloads in a terminal state ({@link Download#STATE_COMPLETED},
+ * {@link Download#STATE_FAILED}).
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param stopReason The stop reason.
+ * @throws IOException If an error occurs updating the state.
+ */
+ void setStopReason(int stopReason) throws IOException;
+
+ /**
+ * Sets the stop reason of the download with the given ID in a terminal state ({@link
+ * Download#STATE_COMPLETED}, {@link Download#STATE_FAILED}). Does nothing if a download with the
+ * given ID does not exist, or if it's not in a terminal state.
+ *
+ * <p>This method may be slow and shouldn't normally be called on the main thread.
+ *
+ * @param id The ID of the download to update.
+ * @param stopReason The stop reason.
+ * @throws IOException If an error occurs updating the state.
+ */
+ void setStopReason(String id, int stopReason) throws IOException;
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/package-info.java
new file mode 100644
index 0000000000..a353e22107
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.offline;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;