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 mutableKeys = new ArrayList<>(keys); Collections.sort(mutableKeys); this.keys = Collections.unmodifiableList(mutableKeys); - this.data = data != null ? data : Util.EMPTY_BYTE_ARRAY; + this.data = data != null ? Arrays.copyOf(data, data.length) : Util.EMPTY_BYTE_ARRAY; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java new file mode 100644 index 0000000000..71726b64a9 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadIndex.java @@ -0,0 +1,52 @@ +/* + * 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.support.annotation.Nullable; + +/** Persists {@link DownloadState}s. */ +interface DownloadIndex { + /** Releases the used resources. */ + void release(); + + /** + * Returns the {@link DownloadState} with the given {@code id}, or null. + * + * @param id ID of a {@link DownloadState}. + * @return The {@link DownloadState} with the given {@code id}, or null if a download state with + * this id doesn't exist. + */ + @Nullable + DownloadState getDownloadState(String id); + + /** + * Returns a {@link DownloadStateCursor} to {@link DownloadState}s with the given {@code states}. + * + * @param states Returns only the {@link DownloadState}s with this states. If empty, returns all. + * @return A cursor to {@link DownloadState}s with the given {@code states}. + */ + DownloadStateCursor getDownloadStates(@DownloadState.State int... states); + + /** + * Adds or replaces a {@link DownloadState}. + * + * @param downloadState The {@link DownloadState} to be added. + */ + void putDownloadState(DownloadState downloadState); + + /** Removes the {@link DownloadState} with the given {@code id}. */ + void removeDownloadState(String id); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java index eed32720a3..7bbd078822 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadState.java @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.android.exoplayer2.offline; import android.net.Uri; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java new file mode 100644 index 0000000000..680976c77b --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadStateCursor.java @@ -0,0 +1,127 @@ +/* + * 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; + +/** Provides random read-write access to the result set returned by a database query. */ +interface DownloadStateCursor { + + /** Returns the DownloadState at the current position. */ + DownloadState getDownloadState(); + + /** Returns the numbers of DownloadStates in the cursor. */ + int getCount(); + + /** + * Returns the current position of the cursor in the DownloadState set. The value is zero-based. + * When the DownloadState set is first returned the cursor will be at positon -1, which is before + * the first DownloadState. After the last DownloadState 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. + * + *

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. + } + } +}