diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java new file mode 100644 index 0000000000..8881a038f9 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java @@ -0,0 +1,528 @@ +/* + * 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 com.google.android.exoplayer2.offline; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A {@link DownloadIndex} which uses SQLite to persist {@link DownloadState}s. + * + *
Database access may take a long time, do not call methods of this class from + * the application main thread. + */ +public final class DefaultDownloadIndex implements DownloadIndex { + + /** Provides {@link SQLiteDatabase} instances. */ + public interface DatabaseProvider { + /** Closes any open database object. */ + void close(); + + /** + * Creates and/or opens a database that will be used for reading and writing. + * + *
Once opened successfully, the database is cached, so you can call this method every time + * you need to write to the database. (Make sure to call {@link #close} when you no longer need + * the database.) Errors such as bad permissions or a full disk may cause this method to fail, + * but future attempts may succeed if the problem is fixed. + * + * @throws SQLiteException If the database cannot be opened for writing. + * @return A read/write database object valid until {@link #close} is called. + */ + SQLiteDatabase getWritableDatabase(); + + /** + * Creates and/or opens a database. This will be the same object returned by {@link + * #getWritableDatabase} unless some problem, such as a full disk, requires the database to be + * opened read-only. In that case, a read-only database object will be returned. If the problem + * is fixed, a future call to {@link #getWritableDatabase} may succeed, in which case the + * read-only database object will be closed and the read/write object will be returned in the + * future. + * + *
Once opened successfully, the database should be cached. When the database is no longer
+ * needed, {@link #close} will be called.
+ *
+ * @throws SQLiteException If the database cannot be opened.
+ * @return A database object valid until {@link #getWritableDatabase} or {@link #close} is
+ * called.
+ */
+ SQLiteDatabase getReadableDatabase();
+ }
+
+ private static final String DATABASE_NAME = "exoplayer_internal.db";
+
+ private final DatabaseProvider databaseProvider;
+ @Nullable private DownloadStateTable downloadStateTable;
+
+ /**
+ * Creates a DefaultDownloadIndex which stores the {@link DownloadState}s on a SQLite database.
+ *
+ * @param context A Context.
+ */
+ public DefaultDownloadIndex(Context context) {
+ this(new DefaultDatabaseProvider(context));
+ }
+
+ /**
+ * Creates a DefaultDownloadIndex which stores the {@link DownloadState}s on a SQLite database
+ * provided by {@code databaseProvider}.
+ *
+ * @param databaseProvider A DatabaseProvider which provides the database which will be used to
+ * store DownloadStatus table.
+ */
+ public DefaultDownloadIndex(DatabaseProvider databaseProvider) {
+ this.databaseProvider = databaseProvider;
+ }
+
+ @Override
+ public void release() {
+ databaseProvider.close();
+ }
+
+ @Override
+ @Nullable
+ public DownloadState getDownloadState(String id) {
+ return getDownloadStateTable().get(id);
+ }
+
+ @Override
+ public DownloadStateCursor getDownloadStates(@DownloadState.State int... states) {
+ return getDownloadStateTable().get(states);
+ }
+
+ @Override
+ public void putDownloadState(DownloadState downloadState) {
+ getDownloadStateTable().replace(downloadState);
+ }
+
+ @Override
+ public void removeDownloadState(String id) {
+ getDownloadStateTable().delete(id);
+ }
+
+ private DownloadStateTable getDownloadStateTable() {
+ if (downloadStateTable == null) {
+ downloadStateTable = new DownloadStateTable(databaseProvider);
+ }
+ return downloadStateTable;
+ }
+
+ @VisibleForTesting
+ /* package */ static boolean doesTableExist(DatabaseProvider databaseProvider, String tableName) {
+ SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase();
+ long count =
+ DatabaseUtils.queryNumEntries(
+ readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName});
+ return count > 0;
+ }
+
+ private static final class DownloadStateCursorImpl implements DownloadStateCursor {
+
+ private final Cursor cursor;
+
+ private DownloadStateCursorImpl(Cursor cursor) {
+ this.cursor = cursor;
+ }
+
+ @Override
+ public DownloadState getDownloadState() {
+ return DownloadStateTable.getDownloadState(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();
+ }
+ }
+
+ @VisibleForTesting
+ /* package */ static final class DownloadStateTable {
+ @VisibleForTesting /* package */ static final String TABLE_NAME = "ExoPlayerDownloadStates";
+ @VisibleForTesting /* package */ static final int TABLE_VERSION = 1;
+
+ private static final String COLUMN_ID = "id";
+ private static final String COLUMN_TYPE = "title";
+ private static final String COLUMN_URI = "subtitle";
+ private static final String COLUMN_CACHE_KEY = "cache_key";
+ private static final String COLUMN_STATE = "state";
+ private static final String COLUMN_DOWNLOAD_PERCENTAGE = "download_percentage";
+ private static final String COLUMN_DOWNLOADED_BYTES = "downloaded_bytes";
+ private static final String COLUMN_TOTAL_BYTES = "total_bytes";
+ private static final String COLUMN_FAILURE_REASON = "failure_reason";
+ private static final String COLUMN_STOP_FLAGS = "stop_flags";
+ 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_STREAM_KEYS = "stream_keys";
+ private static final String COLUMN_CUSTOM_METADATA = "custom_metadata";
+
+ 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_CACHE_KEY = 3;
+ private static final int COLUMN_INDEX_STATE = 4;
+ private static final int COLUMN_INDEX_DOWNLOAD_PERCENTAGE = 5;
+ private static final int COLUMN_INDEX_DOWNLOADED_BYTES = 6;
+ private static final int COLUMN_INDEX_TOTAL_BYTES = 7;
+ private static final int COLUMN_INDEX_FAILURE_REASON = 8;
+ private static final int COLUMN_INDEX_STOP_FLAGS = 9;
+ private static final int COLUMN_INDEX_START_TIME_MS = 10;
+ private static final int COLUMN_INDEX_UPDATE_TIME_MS = 11;
+ private static final int COLUMN_INDEX_STREAM_KEYS = 12;
+ private static final int COLUMN_INDEX_CUSTOM_METADATA = 13;
+
+ private static final String COLUMN_SELECTION_ID = COLUMN_ID + " = ?";
+
+ private static final String[] COLUMNS =
+ new String[] {
+ COLUMN_ID,
+ COLUMN_TYPE,
+ COLUMN_URI,
+ COLUMN_CACHE_KEY,
+ COLUMN_STATE,
+ COLUMN_DOWNLOAD_PERCENTAGE,
+ COLUMN_DOWNLOADED_BYTES,
+ COLUMN_TOTAL_BYTES,
+ COLUMN_FAILURE_REASON,
+ COLUMN_STOP_FLAGS,
+ COLUMN_START_TIME_MS,
+ COLUMN_UPDATE_TIME_MS,
+ COLUMN_STREAM_KEYS,
+ COLUMN_CUSTOM_METADATA
+ };
+
+ private static final String SQL_DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE_NAME;
+ private static final String SQL_CREATE_TABLE =
+ "CREATE TABLE IF NOT EXISTS "
+ + TABLE_NAME
+ + " ("
+ + COLUMN_ID
+ + " TEXT PRIMARY KEY NOT NULL,"
+ + COLUMN_TYPE
+ + " TEXT NOT NULL,"
+ + COLUMN_URI
+ + " TEXT NOT NULL,"
+ + COLUMN_CACHE_KEY
+ + " TEXT,"
+ + COLUMN_STATE
+ + " INTEGER NOT NULL,"
+ + COLUMN_DOWNLOAD_PERCENTAGE
+ + " REAL NOT NULL,"
+ + COLUMN_DOWNLOADED_BYTES
+ + " INTEGER NOT NULL,"
+ + COLUMN_TOTAL_BYTES
+ + " INTEGER NOT NULL,"
+ + COLUMN_FAILURE_REASON
+ + " INTEGER NOT NULL,"
+ + COLUMN_STOP_FLAGS
+ + " INTEGER NOT NULL,"
+ + COLUMN_START_TIME_MS
+ + " INTEGER NOT NULL,"
+ + COLUMN_UPDATE_TIME_MS
+ + " INTEGER NOT NULL,"
+ + COLUMN_STREAM_KEYS
+ + " TEXT NOT NULL,"
+ + COLUMN_CUSTOM_METADATA
+ + " BLOB NOT NULL)";
+
+ private final DatabaseProvider databaseProvider;
+
+ public DownloadStateTable(DatabaseProvider databaseProvider) {
+ this.databaseProvider = databaseProvider;
+ VersionTable versionTable = new VersionTable(databaseProvider);
+ int version = versionTable.getVersion(VersionTable.FEATURE_OFFLINE);
+ if (!doesTableExist(databaseProvider, TABLE_NAME)
+ || version == 0
+ || version > TABLE_VERSION) {
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.beginTransaction();
+ try {
+ writableDatabase.execSQL(SQL_DROP_TABLE);
+ writableDatabase.execSQL(SQL_CREATE_TABLE);
+ versionTable.setVersion(VersionTable.FEATURE_OFFLINE, TABLE_VERSION);
+ writableDatabase.setTransactionSuccessful();
+ } finally {
+ writableDatabase.endTransaction();
+ }
+ } else if (version < TABLE_VERSION) {
+ // There is no previous version currently.
+ throw new IllegalStateException();
+ }
+ }
+
+ public void replace(DownloadState downloadState) {
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_ID, downloadState.id);
+ values.put(COLUMN_TYPE, downloadState.type);
+ values.put(COLUMN_URI, downloadState.uri.toString());
+ values.put(COLUMN_CACHE_KEY, downloadState.cacheKey);
+ values.put(COLUMN_STATE, downloadState.state);
+ values.put(COLUMN_DOWNLOAD_PERCENTAGE, downloadState.downloadPercentage);
+ values.put(COLUMN_DOWNLOADED_BYTES, downloadState.downloadedBytes);
+ values.put(COLUMN_TOTAL_BYTES, downloadState.totalBytes);
+ values.put(COLUMN_FAILURE_REASON, downloadState.failureReason);
+ values.put(COLUMN_STOP_FLAGS, downloadState.stopFlags);
+ values.put(COLUMN_START_TIME_MS, downloadState.startTimeMs);
+ values.put(COLUMN_UPDATE_TIME_MS, downloadState.updateTimeMs);
+ values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(downloadState.streamKeys));
+ values.put(COLUMN_CUSTOM_METADATA, downloadState.customMetadata);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values);
+ }
+
+ @Nullable
+ public DownloadState get(String id) {
+ String[] selectionArgs = {id};
+ try (Cursor cursor = query(COLUMN_SELECTION_ID, selectionArgs)) {
+ if (cursor.getCount() == 0) {
+ return null;
+ }
+ cursor.moveToNext();
+ DownloadState downloadState = getDownloadState(cursor);
+ Assertions.checkState(id.equals(downloadState.id));
+ return downloadState;
+ }
+ }
+
+ public DownloadStateCursor get(@DownloadState.State int... states) {
+ String selection = null;
+ if (states.length > 0) {
+ 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(')');
+ selection = selectionBuilder.toString();
+ }
+ Cursor cursor = query(selection, /* selectionArgs= */ null);
+ return new DownloadStateCursorImpl(cursor);
+ }
+
+ public void delete(String id) {
+ String[] selectionArgs = {id};
+ databaseProvider.getWritableDatabase().delete(TABLE_NAME, COLUMN_SELECTION_ID, selectionArgs);
+ }
+
+ private Cursor query(@Nullable String selection, @Nullable String[] selectionArgs) {
+ String sortOrder = COLUMN_START_TIME_MS + " ASC";
+ return databaseProvider
+ .getReadableDatabase()
+ .query(
+ TABLE_NAME,
+ COLUMNS,
+ selection,
+ selectionArgs,
+ /* groupBy= */ null,
+ /* having= */ null,
+ sortOrder);
+ }
+
+ private static DownloadState getDownloadState(Cursor cursor) {
+ return new DownloadState(
+ cursor.getString(COLUMN_INDEX_ID),
+ cursor.getString(COLUMN_INDEX_TYPE),
+ Uri.parse(cursor.getString(COLUMN_INDEX_URI)),
+ cursor.getString(COLUMN_INDEX_CACHE_KEY),
+ cursor.getInt(COLUMN_INDEX_STATE),
+ cursor.getFloat(COLUMN_INDEX_DOWNLOAD_PERCENTAGE),
+ cursor.getLong(COLUMN_INDEX_DOWNLOADED_BYTES),
+ cursor.getLong(COLUMN_INDEX_TOTAL_BYTES),
+ cursor.getInt(COLUMN_INDEX_FAILURE_REASON),
+ cursor.getInt(COLUMN_INDEX_STOP_FLAGS),
+ cursor.getLong(COLUMN_INDEX_START_TIME_MS),
+ cursor.getLong(COLUMN_INDEX_UPDATE_TIME_MS),
+ decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS)),
+ cursor.getBlob(COLUMN_INDEX_CUSTOM_METADATA));
+ }
+
+ private static String encodeStreamKeys(StreamKey[] streamKeys) {
+ StringBuilder stringBuilder = new StringBuilder();
+ for (StreamKey streamKey : streamKeys) {
+ 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 StreamKey[] decodeStreamKeys(String encodedStreamKeys) {
+ if (encodedStreamKeys.isEmpty()) {
+ return new StreamKey[0];
+ }
+ String[] streamKeysStrings = Util.split(encodedStreamKeys, ",");
+ int streamKeysCount = streamKeysStrings.length;
+ StreamKey[] streamKeys = new StreamKey[streamKeysCount];
+ for (int i = 0; i < streamKeysCount; i++) {
+ String[] indices = Util.split(streamKeysStrings[i], "\\.");
+ Assertions.checkState(indices.length == 3);
+ streamKeys[i] =
+ new StreamKey(
+ Integer.parseInt(indices[0]),
+ Integer.parseInt(indices[1]),
+ Integer.parseInt(indices[2]));
+ }
+ return streamKeys;
+ }
+ }
+
+ @VisibleForTesting
+ /* package */ static final class VersionTable {
+ private static final String TABLE_NAME = "ExoPlayerVersions";
+
+ private static final String COLUMN_FEATURE = "feature";
+ private static final String COLUMN_VERSION = "version";
+
+ private static final String SQL_CREATE_TABLE =
+ "CREATE TABLE IF NOT EXISTS "
+ + TABLE_NAME
+ + " ("
+ + COLUMN_FEATURE
+ + " INTEGER PRIMARY KEY NOT NULL,"
+ + COLUMN_VERSION
+ + " INTEGER NOT NULL)";
+
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({FEATURE_OFFLINE, FEATURE_CACHE})
+ private @interface Feature {}
+
+ public static final int FEATURE_OFFLINE = 0;
+ public static final int FEATURE_CACHE = 1;
+
+ private final DatabaseProvider databaseProvider;
+
+ public VersionTable(DatabaseProvider databaseProvider) {
+ this.databaseProvider = databaseProvider;
+ if (!doesTableExist(databaseProvider, TABLE_NAME)) {
+ databaseProvider.getWritableDatabase().execSQL(SQL_CREATE_TABLE);
+ }
+ }
+
+ public void setVersion(@Feature int feature, int version) {
+ ContentValues values = new ContentValues();
+ values.put(COLUMN_FEATURE, feature);
+ values.put(COLUMN_VERSION, version);
+ SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
+ writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values);
+ }
+
+ public int getVersion(@Feature int feature) {
+ String selection = COLUMN_FEATURE + " = ?";
+ String[] selectionArgs = {Integer.toString(feature)};
+ try (Cursor cursor =
+ databaseProvider
+ .getReadableDatabase()
+ .query(
+ TABLE_NAME,
+ new String[] {COLUMN_VERSION},
+ selection,
+ selectionArgs,
+ /* groupBy= */ null,
+ /* having= */ null,
+ /* orderBy= */ null)) {
+ if (cursor.getCount() == 0) {
+ return 0;
+ }
+ cursor.moveToNext();
+ return cursor.getInt(/* COLUMN_VERSION index */ 0);
+ }
+ }
+ }
+
+ private static final class DefaultDatabaseProvider extends SQLiteOpenHelper
+ implements DatabaseProvider {
+ public DefaultDatabaseProvider(Context context) {
+ super(context, DATABASE_NAME, /* factory= */ null, /* version= */ 1);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ // Table creation is done in DownloadStateTable constructor.
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // Upgrade is handled in DownloadStateTable constructor.
+ }
+
+ @Override
+ public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO: Wipe the database.
+ super.onDowngrade(db, oldVersion, newVersion);
+ }
+
+ // DatabaseProvider implementation.
+
+ @Override
+ public synchronized void close() {
+ super.close();
+ }
+
+ @Override
+ public SQLiteDatabase getWritableDatabase() {
+ return super.getWritableDatabase();
+ }
+
+ @Override
+ public SQLiteDatabase getReadableDatabase() {
+ return super.getReadableDatabase();
+ }
+ }
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java
index d809ab4754..40ea094b5e 100644
--- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java
+++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadAction.java
@@ -156,7 +156,7 @@ public final class DownloadAction {
ArrayList 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 DownloadState.
+ *
+ * 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 DownloadState.
+ *
+ * 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 DownloadState.
+ *
+ * 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 DownloadState.
+ *
+ * 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 DownloadState. */
+ default boolean isFirst() {
+ return getPosition() == 0 && getCount() != 0;
+ }
+
+ /** Returns whether the cursor is pointing to the last DownloadState. */
+ default boolean isLast() {
+ int count = getCount();
+ return getPosition() == (count - 1) && count != 0;
+ }
+
+ /** Returns whether the cursor is pointing to the position before the first DownloadState. */
+ default boolean isBeforeFirst() {
+ if (getCount() == 0) {
+ return true;
+ }
+ return getPosition() == -1;
+ }
+
+ /** Returns whether the cursor is pointing to the position after the last DownloadState. */
+ default boolean isAfterLast() {
+ if (getCount() == 0) {
+ return true;
+ }
+ return getPosition() == getCount();
+ }
+
+ /** Closes the Cursor, releasing all of its resources and making it completely invalid. */
+ void close();
+
+ /** Returns whether the cursor is closed */
+ boolean isClosed();
+}
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java
new file mode 100644
index 0000000000..fe32415ffa
--- /dev/null
+++ b/library/core/src/test/java/com/google/android/exoplayer2/offline/DefaultDownloadIndexTest.java
@@ -0,0 +1,491 @@
+/*
+ * 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 com.google.android.exoplayer2.offline;
+
+import static com.google.android.exoplayer2.offline.DefaultDownloadIndex.VersionTable.FEATURE_CACHE;
+import static com.google.android.exoplayer2.offline.DefaultDownloadIndex.VersionTable.FEATURE_OFFLINE;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import com.google.android.exoplayer2.C;
+import java.util.Arrays;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+/** Unit tests for {@link DefaultDownloadIndex}. */
+@RunWith(RobolectricTestRunner.class)
+public class DefaultDownloadIndexTest {
+
+ private DefaultDownloadIndex downloadIndex;
+
+ @Before
+ public void setUp() {
+ downloadIndex = new DefaultDownloadIndex(RuntimeEnvironment.application);
+ }
+
+ @After
+ public void tearDown() {
+ downloadIndex.release();
+ }
+
+ @Test
+ public void getDownloadState_nonExistingId_returnsNull() {
+ assertThat(downloadIndex.getDownloadState("non existing id")).isNull();
+ }
+
+ @Test
+ public void addAndGetDownloadState_nonExistingId_returnsTheSameDownloadState() {
+ String id = "id";
+ DownloadState downloadState = new DownloadStateBuilder(id).build();
+
+ downloadIndex.putDownloadState(downloadState);
+ DownloadState readDownloadState = downloadIndex.getDownloadState(id);
+
+ assertEqual(readDownloadState, downloadState);
+ }
+
+ @Test
+ public void addAndGetDownloadState_existingId_returnsUpdatedDownloadState() {
+ String id = "id";
+ DownloadStateBuilder downloadStateBuilder = new DownloadStateBuilder(id);
+ downloadIndex.putDownloadState(downloadStateBuilder.build());
+
+ DownloadState downloadState =
+ downloadStateBuilder
+ .setType("different type")
+ .setUri("different uri")
+ .setCacheKey("different cacheKey")
+ .setState(DownloadState.STATE_FAILED)
+ .setDownloadPercentage(50)
+ .setDownloadedBytes(200)
+ .setTotalBytes(400)
+ .setFailureReason(DownloadState.FAILURE_REASON_UNKNOWN)
+ .setStopFlags(DownloadState.STOP_FLAG_STOPPED)
+ .setStartTimeMs(10)
+ .setUpdateTimeMs(20)
+ .setStreamKeys(
+ new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 1, /* trackIndex= */ 2),
+ new StreamKey(/* periodIndex= */ 3, /* groupIndex= */ 4, /* trackIndex= */ 5))
+ .setCustomMetadata(new byte[] {0, 1, 2, 3})
+ .build();
+ downloadIndex.putDownloadState(downloadState);
+ DownloadState readDownloadState = downloadIndex.getDownloadState(id);
+
+ assertThat(readDownloadState).isNotNull();
+ assertEqual(readDownloadState, downloadState);
+ }
+
+ @Test
+ public void releaseAndRecreateDownloadIndex_returnsTheSameDownloadState() {
+ String id = "id";
+ DownloadState downloadState = new DownloadStateBuilder(id).build();
+
+ downloadIndex.putDownloadState(downloadState);
+ downloadIndex.release();
+ downloadIndex = new DefaultDownloadIndex(RuntimeEnvironment.application);
+ DownloadState readDownloadState = downloadIndex.getDownloadState(id);
+
+ assertThat(readDownloadState).isNotNull();
+ assertEqual(readDownloadState, downloadState);
+ }
+
+ @Test
+ public void customDatabaseProvider_getDownloadStateReturnsNull() {
+ String id = "id";
+ DownloadState downloadState = new DownloadStateBuilder(id).build();
+
+ downloadIndex.putDownloadState(downloadState);
+ downloadIndex.release();
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+ downloadIndex = new DefaultDownloadIndex(databaseProvider);
+ DownloadState readDownloadState = downloadIndex.getDownloadState(id);
+
+ assertThat(readDownloadState).isNull();
+ databaseProvider.close();
+ }
+
+ @Test
+ public void removeDownloadState_nonExistingId_doesNotFail() {
+ downloadIndex.removeDownloadState("non existing id");
+ }
+
+ @Test
+ public void removeDownloadState_existingId_getDownloadStateReturnsNull() {
+ String id = "id";
+ DownloadState downloadState = new DownloadStateBuilder(id).build();
+ downloadIndex.putDownloadState(downloadState);
+
+ downloadIndex.removeDownloadState(id);
+ DownloadState readDownloadState = downloadIndex.getDownloadState(id);
+
+ assertThat(readDownloadState).isNull();
+ }
+
+ @Test
+ public void getDownloadStates_emptyDownloadIndex_returnsEmptyArray() {
+ assertThat(downloadIndex.getDownloadStates().getCount()).isEqualTo(0);
+ }
+
+ @Test
+ public void getDownloadStates_noState_returnsAllDownloadStatusSortedByStartTime() {
+ DownloadState downloadState1 = new DownloadStateBuilder("id1").setStartTimeMs(1).build();
+ downloadIndex.putDownloadState(downloadState1);
+ DownloadState downloadState2 = new DownloadStateBuilder("id2").setStartTimeMs(0).build();
+ downloadIndex.putDownloadState(downloadState2);
+
+ DownloadStateCursor cursor = downloadIndex.getDownloadStates();
+
+ assertThat(cursor.getCount()).isEqualTo(2);
+ cursor.moveToNext();
+ assertEqual(cursor.getDownloadState(), downloadState2);
+ cursor.moveToNext();
+ assertEqual(cursor.getDownloadState(), downloadState1);
+ }
+
+ @Test
+ public void getDownloadStates_withStates_returnsAllDownloadStatusWithTheSameStates() {
+ DownloadState downloadState1 =
+ new DownloadStateBuilder("id1")
+ .setStartTimeMs(0)
+ .setState(DownloadState.STATE_REMOVED)
+ .build();
+ downloadIndex.putDownloadState(downloadState1);
+ DownloadState downloadState2 =
+ new DownloadStateBuilder("id2")
+ .setStartTimeMs(1)
+ .setState(DownloadState.STATE_STOPPED)
+ .build();
+ downloadIndex.putDownloadState(downloadState2);
+ DownloadState downloadState3 =
+ new DownloadStateBuilder("id3")
+ .setStartTimeMs(2)
+ .setState(DownloadState.STATE_COMPLETED)
+ .build();
+ downloadIndex.putDownloadState(downloadState3);
+
+ DownloadStateCursor cursor =
+ downloadIndex.getDownloadStates(DownloadState.STATE_REMOVED, DownloadState.STATE_COMPLETED);
+
+ assertThat(cursor.getCount()).isEqualTo(2);
+ cursor.moveToNext();
+ assertEqual(cursor.getDownloadState(), downloadState1);
+ cursor.moveToNext();
+ assertEqual(cursor.getDownloadState(), downloadState3);
+ }
+
+ @Test
+ public void doesTableExist_nonExistingTable_returnsFalse() {
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+
+ assertThat(DefaultDownloadIndex.doesTableExist(databaseProvider, "NonExistingTable")).isFalse();
+
+ databaseProvider.close();
+ }
+
+ @Test
+ public void doesTableExist_existingTable_returnsTrue() {
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+ String tableName = "ExistingTable";
+ databaseProvider.getWritableDatabase().execSQL("CREATE TABLE " + tableName + "(dummy)");
+
+ assertThat(DefaultDownloadIndex.doesTableExist(databaseProvider, tableName)).isTrue();
+
+ databaseProvider.close();
+ }
+
+ @Test
+ public void getVersion_nonExistingTable_returnsZero() {
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+ DefaultDownloadIndex.VersionTable versionTable =
+ new DefaultDownloadIndex.VersionTable(databaseProvider);
+
+ int version = versionTable.getVersion(FEATURE_OFFLINE);
+
+ assertThat(version).isEqualTo(0);
+ databaseProvider.close();
+ }
+
+ @Test
+ public void getVersion_returnsSetVersion() {
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+ DefaultDownloadIndex.VersionTable versionTable =
+ new DefaultDownloadIndex.VersionTable(databaseProvider);
+
+ versionTable.setVersion(FEATURE_OFFLINE, 1);
+ assertThat(versionTable.getVersion(FEATURE_OFFLINE)).isEqualTo(1);
+
+ versionTable.setVersion(FEATURE_OFFLINE, 10);
+ assertThat(versionTable.getVersion(FEATURE_OFFLINE)).isEqualTo(10);
+
+ versionTable.setVersion(FEATURE_CACHE, 5);
+ assertThat(versionTable.getVersion(FEATURE_CACHE)).isEqualTo(5);
+ assertThat(versionTable.getVersion(FEATURE_OFFLINE)).isEqualTo(10);
+
+ databaseProvider.close();
+ }
+
+ @Test
+ public void downloadStateTableConstructor_noTable_createsTable() {
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+ assertThat(
+ DefaultDownloadIndex.doesTableExist(
+ databaseProvider, DefaultDownloadIndex.DownloadStateTable.TABLE_NAME))
+ .isFalse();
+
+ new DefaultDownloadIndex.DownloadStateTable(databaseProvider);
+
+ assertThat(
+ DefaultDownloadIndex.doesTableExist(
+ databaseProvider, DefaultDownloadIndex.DownloadStateTable.TABLE_NAME))
+ .isTrue();
+
+ databaseProvider.close();
+ }
+
+ @Test
+ public void downloadStateTableConstructor_versionZero_versionSet() {
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+
+ new DefaultDownloadIndex.DownloadStateTable(databaseProvider);
+
+ DefaultDownloadIndex.VersionTable versionTable =
+ new DefaultDownloadIndex.VersionTable(databaseProvider);
+ assertThat(versionTable.getVersion(FEATURE_OFFLINE))
+ .isEqualTo(DefaultDownloadIndex.DownloadStateTable.TABLE_VERSION);
+ databaseProvider.close();
+ }
+
+ @Test
+ public void downloadStateTableConstructor_greaterVersion_tableRecreated() {
+ DatabaseProviderImpl databaseProvider = new DatabaseProviderImpl();
+ databaseProvider
+ .getWritableDatabase()
+ .execSQL("CREATE TABLE " + DefaultDownloadIndex.DownloadStateTable.TABLE_NAME + "(dummy)");
+ DefaultDownloadIndex.VersionTable versionTable =
+ new DefaultDownloadIndex.VersionTable(databaseProvider);
+ versionTable.setVersion(FEATURE_OFFLINE, Integer.MAX_VALUE);
+
+ DefaultDownloadIndex.DownloadStateTable downloadStateTable =
+ new DefaultDownloadIndex.DownloadStateTable(databaseProvider);
+ String id = "id";
+ DownloadState downloadState = new DownloadStateBuilder(id).build();
+ downloadStateTable.replace(downloadState);
+ DownloadState readDownloadState = downloadStateTable.get(id);
+ assertEqual(readDownloadState, downloadState);
+
+ assertThat(versionTable.getVersion(FEATURE_OFFLINE))
+ .isEqualTo(DefaultDownloadIndex.DownloadStateTable.TABLE_VERSION);
+ databaseProvider.close();
+ }
+
+ private static void assertEqual(DownloadState downloadState, DownloadState expected) {
+ assertThat(areEqual(downloadState, expected)).isTrue();
+ }
+
+ private static boolean areEqual(DownloadState downloadState, DownloadState that) {
+ if (downloadState.state != that.state) {
+ return false;
+ }
+ if (Float.compare(that.downloadPercentage, downloadState.downloadPercentage) != 0) {
+ return false;
+ }
+ if (downloadState.downloadedBytes != that.downloadedBytes) {
+ return false;
+ }
+ if (downloadState.totalBytes != that.totalBytes) {
+ return false;
+ }
+ if (downloadState.startTimeMs != that.startTimeMs) {
+ return false;
+ }
+ if (downloadState.updateTimeMs != that.updateTimeMs) {
+ return false;
+ }
+ if (downloadState.failureReason != that.failureReason) {
+ return false;
+ }
+ if (downloadState.stopFlags != that.stopFlags) {
+ return false;
+ }
+ if (!downloadState.id.equals(that.id)) {
+ return false;
+ }
+ if (!downloadState.type.equals(that.type)) {
+ return false;
+ }
+ if (!downloadState.uri.equals(that.uri)) {
+ return false;
+ }
+ if (downloadState.cacheKey != null
+ ? !downloadState.cacheKey.equals(that.cacheKey)
+ : that.cacheKey != null) {
+ return false;
+ }
+ if (!Arrays.equals(downloadState.streamKeys, that.streamKeys)) {
+ return false;
+ }
+ return Arrays.equals(downloadState.customMetadata, that.customMetadata);
+ }
+
+ private static class DownloadStateBuilder {
+ private String id;
+ private String type;
+ private String uri;
+ @Nullable private String cacheKey;
+ private int state;
+ private float downloadPercentage;
+ private long downloadedBytes;
+ private long totalBytes;
+ private int failureReason;
+ private int stopFlags;
+ private long startTimeMs;
+ private long updateTimeMs;
+ private StreamKey[] streamKeys;
+ private byte[] customMetadata;
+
+ private DownloadStateBuilder(String id) {
+ this.id = id;
+ this.type = "type";
+ this.uri = "uri";
+ this.cacheKey = null;
+ this.state = DownloadState.STATE_QUEUED;
+ this.downloadPercentage = (float) C.PERCENTAGE_UNSET;
+ this.downloadedBytes = (long) 0;
+ this.totalBytes = (long) C.LENGTH_UNSET;
+ this.failureReason = DownloadState.FAILURE_REASON_NONE;
+ this.stopFlags = 0;
+ this.startTimeMs = (long) 0;
+ this.updateTimeMs = (long) 0;
+ this.streamKeys = new StreamKey[0];
+ this.customMetadata = new byte[0];
+ }
+
+ public DownloadStateBuilder setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public DownloadStateBuilder setType(String type) {
+ this.type = type;
+ return this;
+ }
+
+ public DownloadStateBuilder setUri(String uri) {
+ this.uri = uri;
+ return this;
+ }
+
+ public DownloadStateBuilder setCacheKey(@Nullable String cacheKey) {
+ this.cacheKey = cacheKey;
+ return this;
+ }
+
+ public DownloadStateBuilder setState(int state) {
+ this.state = state;
+ return this;
+ }
+
+ public DownloadStateBuilder setDownloadPercentage(float downloadPercentage) {
+ this.downloadPercentage = downloadPercentage;
+ return this;
+ }
+
+ public DownloadStateBuilder setDownloadedBytes(long downloadedBytes) {
+ this.downloadedBytes = downloadedBytes;
+ return this;
+ }
+
+ public DownloadStateBuilder setTotalBytes(long totalBytes) {
+ this.totalBytes = totalBytes;
+ return this;
+ }
+
+ public DownloadStateBuilder setFailureReason(int failureReason) {
+ this.failureReason = failureReason;
+ return this;
+ }
+
+ public DownloadStateBuilder setStopFlags(int stopFlags) {
+ this.stopFlags = stopFlags;
+ return this;
+ }
+
+ public DownloadStateBuilder setStartTimeMs(long startTimeMs) {
+ this.startTimeMs = startTimeMs;
+ return this;
+ }
+
+ public DownloadStateBuilder setUpdateTimeMs(long updateTimeMs) {
+ this.updateTimeMs = updateTimeMs;
+ return this;
+ }
+
+ public DownloadStateBuilder setStreamKeys(StreamKey... streamKeys) {
+ this.streamKeys = streamKeys;
+ return this;
+ }
+
+ public DownloadStateBuilder setCustomMetadata(byte[] customMetadata) {
+ this.customMetadata = customMetadata;
+ return this;
+ }
+
+ public DownloadState build() {
+ return new DownloadState(
+ id,
+ type,
+ Uri.parse(uri),
+ cacheKey,
+ state,
+ downloadPercentage,
+ downloadedBytes,
+ totalBytes,
+ failureReason,
+ stopFlags,
+ startTimeMs,
+ updateTimeMs,
+ streamKeys,
+ customMetadata);
+ }
+ }
+
+ private static final class DatabaseProviderImpl extends SQLiteOpenHelper
+ implements DefaultDownloadIndex.DatabaseProvider {
+ private static final int DATABASE_VERSION = 1;
+ private static final String DATABASE_NAME = "TestExoPlayerDownloadIndex.db";
+
+ public DatabaseProviderImpl() {
+ super(RuntimeEnvironment.application, DATABASE_NAME, null, DATABASE_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // Do nothing.
+ }
+ }
+}