diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline')
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 <= position <= + * 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; |