Changes for SQLite databases on internal storage

- Remove ability to encrypt content index for SQLite storage
- Remove hack for specifying arbitrary database location

PiperOrigin-RevId: 232863763
This commit is contained in:
olly 2019-02-07 14:38:49 +00:00 committed by Andrew Lewis
parent a7324061b3
commit dd99fdcb82
6 changed files with 75 additions and 185 deletions

View File

@ -16,20 +16,16 @@
package com.google.android.exoplayer2.database; package com.google.android.exoplayer2.database;
import android.content.Context; import android.content.Context;
import android.content.ContextWrapper;
import android.database.Cursor; import android.database.Cursor;
import android.database.DatabaseErrorHandler;
import android.database.SQLException; import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteOpenHelper;
import android.support.annotation.Nullable;
import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Log;
import java.io.File;
/** /**
* An {@link SQLiteOpenHelper} that provides instances of a standalone ExoPlayer database. * An {@link SQLiteOpenHelper} that provides instances of a standalone ExoPlayer database.
* *
* <p>Suitable for use by applications that do not already have their own database, or which would * <p>Suitable for use by applications that do not already have their own database, or that would
* prefer to keep ExoPlayer tables isolated in their own database. Other applications should prefer * prefer to keep ExoPlayer tables isolated in their own database. Other applications should prefer
* to use {@link DefaultDatabaseProvider} with their own {@link SQLiteOpenHelper}. * to use {@link DefaultDatabaseProvider} with their own {@link SQLiteOpenHelper}.
*/ */
@ -51,15 +47,6 @@ public final class ExoDatabaseProvider extends SQLiteOpenHelper implements Datab
super(context.getApplicationContext(), DATABASE_NAME, /* factory= */ null, VERSION); super(context.getApplicationContext(), DATABASE_NAME, /* factory= */ null, VERSION);
} }
/**
* Provides instances of the database located at the specified file.
*
* @param file The database file.
*/
public ExoDatabaseProvider(File file) {
super(new DatabaseFileProvidingContext(file), file.getName(), /* factory= */ null, VERSION);
}
@Override @Override
public void onCreate(SQLiteDatabase db) { public void onCreate(SQLiteDatabase db) {
// Features create their own tables. // Features create their own tables.
@ -105,48 +92,4 @@ public final class ExoDatabaseProvider extends SQLiteOpenHelper implements Datab
} }
} }
} }
// TODO: This is fragile. Stop using it if/when SQLiteOpenHelper can be instantiated without a
// context [Internal ref: b/123351819], or by injecting a Context into all components that need
// to instantiate an ExoDatabaseProvider.
/** A {@link Context} that implements methods called by {@link SQLiteOpenHelper}. */
private static class DatabaseFileProvidingContext extends ContextWrapper {
private final File file;
@SuppressWarnings("nullness:argument.type.incompatible")
public DatabaseFileProvidingContext(File file) {
super(/* base= */ null);
this.file = file;
}
@Override
public File getDatabasePath(String name) {
return file;
}
@Override
public SQLiteDatabase openOrCreateDatabase(
String name, int mode, SQLiteDatabase.CursorFactory factory) {
return openOrCreateDatabase(name, mode, factory, /* errorHandler= */ null);
}
@Override
@SuppressWarnings("nullness:argument.type.incompatible")
public SQLiteDatabase openOrCreateDatabase(
String name,
int mode,
SQLiteDatabase.CursorFactory factory,
@Nullable DatabaseErrorHandler errorHandler) {
File databasePath = getDatabasePath(name);
int flags = SQLiteDatabase.CREATE_IF_NECESSARY;
if ((mode & MODE_ENABLE_WRITE_AHEAD_LOGGING) != 0) {
flags |= SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING;
}
if ((mode & MODE_NO_LOCALIZED_COLLATORS) != 0) {
flags |= SQLiteDatabase.NO_LOCALIZED_COLLATORS;
}
return SQLiteDatabase.openDatabase(databasePath.getPath(), factory, flags, errorHandler);
}
}
} }

View File

@ -45,6 +45,8 @@ public final class VersionTable {
private static final String COLUMN_FEATURE = "feature"; private static final String COLUMN_FEATURE = "feature";
private static final String COLUMN_VERSION = "version"; private static final String COLUMN_VERSION = "version";
private static final String WHERE_FEATURE_EQUALS = COLUMN_FEATURE + " = ?";
private static final String SQL_CREATE_TABLE_IF_NOT_EXISTS = private static final String SQL_CREATE_TABLE_IF_NOT_EXISTS =
"CREATE TABLE IF NOT EXISTS " "CREATE TABLE IF NOT EXISTS "
+ TABLE_NAME + TABLE_NAME
@ -62,7 +64,7 @@ public final class VersionTable {
private VersionTable() {} private VersionTable() {}
/** /**
* Sets the version of tables belonging to the specified feature. * Sets the version of the specified feature.
* *
* @param writableDatabase The database to update. * @param writableDatabase The database to update.
* @param feature The feature. * @param feature The feature.
@ -78,8 +80,21 @@ public final class VersionTable {
} }
/** /**
* Returns the version of tables belonging to the specified feature, or {@link #VERSION_UNSET} if * Removes the version of the specified feature.
* no version information is available. *
* @param writableDatabase The database to update.
* @param feature The feature.
*/
public static void removeVersion(SQLiteDatabase writableDatabase, @Feature int feature) {
if (!tableExists(writableDatabase, TABLE_NAME)) {
return;
}
writableDatabase.delete(TABLE_NAME, WHERE_FEATURE_EQUALS, featureArgument(feature));
}
/**
* Returns the version of the specified feature, or {@link #VERSION_UNSET} if no version
* information is available.
* *
* @param database The database to query. * @param database The database to query.
* @param feature The feature. * @param feature The feature.
@ -88,14 +103,12 @@ public final class VersionTable {
if (!tableExists(database, TABLE_NAME)) { if (!tableExists(database, TABLE_NAME)) {
return VERSION_UNSET; return VERSION_UNSET;
} }
String selection = COLUMN_FEATURE + " = ?";
String[] selectionArgs = {Integer.toString(feature)};
try (Cursor cursor = try (Cursor cursor =
database.query( database.query(
TABLE_NAME, TABLE_NAME,
new String[] {COLUMN_VERSION}, new String[] {COLUMN_VERSION},
selection, WHERE_FEATURE_EQUALS,
selectionArgs, featureArgument(feature),
/* groupBy= */ null, /* groupBy= */ null,
/* having= */ null, /* having= */ null,
/* orderBy= */ null)) { /* orderBy= */ null)) {
@ -114,4 +127,8 @@ public final class VersionTable {
readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName}); readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName});
return count > 0; return count > 0;
} }
private static String[] featureArgument(int feature) {
return new String[] {Integer.toString(feature)};
}
} }

View File

@ -73,7 +73,7 @@ public final class DefaultDownloadIndex implements DownloadIndex {
private static final int COLUMN_INDEX_STREAM_KEYS = 14; private static final int COLUMN_INDEX_STREAM_KEYS = 14;
private static final int COLUMN_INDEX_CUSTOM_METADATA = 15; private static final int COLUMN_INDEX_CUSTOM_METADATA = 15;
private static final String COLUMN_SELECTION_ID = COLUMN_ID + " = ?"; private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?";
private static final String[] COLUMNS = private static final String[] COLUMNS =
new String[] { new String[] {
@ -152,7 +152,7 @@ public final class DefaultDownloadIndex implements DownloadIndex {
@Nullable @Nullable
public DownloadState getDownloadState(String id) { public DownloadState getDownloadState(String id) {
ensureInitialized(); ensureInitialized();
try (Cursor cursor = getCursor(COLUMN_SELECTION_ID, new String[] {id})) { try (Cursor cursor = getCursor(WHERE_ID_EQUALS, new String[] {id})) {
if (cursor.getCount() == 0) { if (cursor.getCount() == 0) {
return null; return null;
} }
@ -210,9 +210,7 @@ public final class DefaultDownloadIndex implements DownloadIndex {
@Override @Override
public void removeDownloadState(String id) { public void removeDownloadState(String id) {
ensureInitialized(); ensureInitialized();
databaseProvider databaseProvider.getWritableDatabase().delete(TABLE_NAME, WHERE_ID_EQUALS, new String[] {id});
.getWritableDatabase()
.delete(TABLE_NAME, COLUMN_SELECTION_ID, new String[] {id});
} }
private void ensureInitialized() { private void ensureInitialized() {

View File

@ -24,7 +24,6 @@ import android.support.annotation.VisibleForTesting;
import android.util.SparseArray; import android.util.SparseArray;
import android.util.SparseBooleanArray; import android.util.SparseBooleanArray;
import com.google.android.exoplayer2.database.DatabaseProvider; import com.google.android.exoplayer2.database.DatabaseProvider;
import com.google.android.exoplayer2.database.ExoDatabaseProvider;
import com.google.android.exoplayer2.database.VersionTable; import com.google.android.exoplayer2.database.VersionTable;
import com.google.android.exoplayer2.upstream.cache.Cache.CacheException; import com.google.android.exoplayer2.upstream.cache.Cache.CacheException;
import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Assertions;
@ -154,9 +153,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
Storage atomicFileStorage = Storage atomicFileStorage =
new AtomicFileStorage( new AtomicFileStorage(
new File(cacheDir, FILE_NAME_ATOMIC), random, encrypt, cipher, secretKeySpec); new File(cacheDir, FILE_NAME_ATOMIC), random, encrypt, cipher, secretKeySpec);
// Storage sqliteStorage = // Storage sqliteStorage = new SQLiteStorage(databaseProvider);
// new SQLiteStorage(
// new File(cacheDir, FILE_NAME_DATABASE), random, encrypt, cipher, secretKeySpec);
storage = atomicFileStorage; storage = atomicFileStorage;
previousStorage = null; previousStorage = null;
} }
@ -170,7 +167,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
storage.storeFully(keyToContent); storage.storeFully(keyToContent);
} catch (CacheException e) { } catch (CacheException e) {
// We failed to copy into current storage, so keep using previous storage. // We failed to copy into current storage, so keep using previous storage.
storage.release();
storage = previousStorage; storage = previousStorage;
previousStorage = null; previousStorage = null;
} }
@ -179,7 +175,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
loadFrom(storage); loadFrom(storage);
} }
if (previousStorage != null) { if (previousStorage != null) {
previousStorage.release(/* delete= */ true); previousStorage.delete();
previousStorage = null; previousStorage = null;
} }
} }
@ -195,14 +191,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
removedIds.clear(); removedIds.clear();
} }
/** Releases any underlying resources. */
public void release() {
storage.release();
if (previousStorage != null) {
previousStorage.release();
}
}
/** /**
* Adds the given key to the index if it isn't there already. * Adds the given key to the index if it isn't there already.
* *
@ -396,13 +384,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
/** Returns whether the persisted index exists. */ /** Returns whether the persisted index exists. */
boolean exists(); boolean exists();
/** Releases any held resources. */ /** Deletes the persisted index. */
default void release() { void delete();
release(/* delete= */ false);
}
/** Releases and held resources and optionally deletes the persisted index. */
void release(boolean delete);
/** /**
* Loads the persisted index into {@code content} and {@code idToKey}, creating it if it doesn't * Loads the persisted index into {@code content} and {@code idToKey}, creating it if it doesn't
@ -479,11 +462,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} }
@Override @Override
public void release(boolean delete) { public void delete() {
if (delete) {
atomicFile.delete(); atomicFile.delete();
} }
}
@Override @Override
public boolean load( public boolean load(
@ -672,26 +653,22 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} }
/** {@link Storage} implementation that uses an SQL database. */ /** {@link Storage} implementation that uses an SQL database. */
// TODO:
// 1. Implement upgrade/downgrade paths from/to AtomicFileStorage.
// 2. If encryption is enabled having previously written data, decide whether we need to rewrite
// the entire table. Currently this implementation only encrypts new and updated entries.
private static final class SQLiteStorage implements Storage { private static final class SQLiteStorage implements Storage {
private static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "CacheContentMetadata"; private static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "CacheContentMetadata";
private static final int TABLE_VERSION = 1; private static final int TABLE_VERSION = 1;
private static final String COLUMN_ID = "id"; private static final String COLUMN_ID = "id";
private static final String COLUMN_FLAGS = "flags"; private static final String COLUMN_KEY = "key";
private static final String COLUMN_DATA = "data"; private static final String COLUMN_METADATA = "metadata";
private static final int COLUMN_INDEX_ID = 0; private static final int COLUMN_INDEX_ID = 0;
private static final int COLUMN_INDEX_FLAGS = 1; private static final int COLUMN_INDEX_KEY = 1;
private static final int COLUMN_INDEX_DATA = 2; private static final int COLUMN_INDEX_METADATA = 2;
private static final String COLUMN_SELECTION_ID = COLUMN_ID + " = ?"; private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?";
private static final String[] COLUMNS = new String[] {COLUMN_ID, COLUMN_FLAGS, COLUMN_DATA}; private static final String[] COLUMNS = new String[] {COLUMN_ID, COLUMN_KEY, COLUMN_METADATA};
private static final String SQL_DROP_TABLE_IF_EXISTS = "DROP TABLE IF EXISTS " + TABLE_NAME; private static final String SQL_DROP_TABLE_IF_EXISTS = "DROP TABLE IF EXISTS " + TABLE_NAME;
private static final String SQL_CREATE_TABLE = private static final String SQL_CREATE_TABLE =
@ -700,52 +677,36 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
+ " (" + " ("
+ COLUMN_ID + COLUMN_ID
+ " INTEGER PRIMARY KEY NOT NULL," + " INTEGER PRIMARY KEY NOT NULL,"
+ COLUMN_FLAGS + COLUMN_KEY
+ " INTEGER NOT NULL," + " TEXT NOT NULL,"
+ COLUMN_DATA + COLUMN_METADATA
+ " BLOB NOT NULL)"; + " BLOB NOT NULL)";
private static final int FLAG_ENCRYPTED = 1; private final DatabaseProvider databaseProvider;
private final File file;
private final Random random;
private final boolean encrypt;
@Nullable private final Cipher cipher;
@Nullable private final SecretKeySpec secretKeySpec;
private final ExoDatabaseProvider databaseProvider;
private final SparseArray<CachedContent> pendingUpdates; private final SparseArray<CachedContent> pendingUpdates;
@Nullable private ReusableBufferedOutputStream bufferedOutputStream; public SQLiteStorage(DatabaseProvider databaseProvider) {
this.databaseProvider = databaseProvider;
public SQLiteStorage(
File file,
Random random,
boolean encrypt,
@Nullable Cipher cipher,
@Nullable SecretKeySpec secretKeySpec) {
this.file = file;
this.random = random;
this.encrypt = encrypt;
this.cipher = cipher;
this.secretKeySpec = secretKeySpec;
databaseProvider = new ExoDatabaseProvider(file);
pendingUpdates = new SparseArray<>(); pendingUpdates = new SparseArray<>();
} }
@Override @Override
public boolean exists() { public boolean exists() {
return file.exists() return VersionTable.getVersion(
&& VersionTable.getVersion( databaseProvider.getReadableDatabase(), VersionTable.FEATURE_CACHE_CONTENT_METADATA)
databaseProvider.getReadableDatabase(),
VersionTable.FEATURE_CACHE_CONTENT_METADATA)
!= VersionTable.VERSION_UNSET; != VersionTable.VERSION_UNSET;
} }
@Override @Override
public void release(boolean delete) { public void delete() {
release(); SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
if (delete) { writableDatabase.beginTransaction();
SQLiteDatabase.deleteDatabase(file); try {
VersionTable.removeVersion(writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA);
writableDatabase.execSQL(SQL_DROP_TABLE_IF_EXISTS);
writableDatabase.setTransactionSuccessful();
} finally {
writableDatabase.endTransaction();
} }
} }
@ -775,23 +736,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
try (Cursor cursor = getCursor()) { try (Cursor cursor = getCursor()) {
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
int id = cursor.getInt(COLUMN_INDEX_ID); int id = cursor.getInt(COLUMN_INDEX_ID);
boolean encrypted = (cursor.getInt(COLUMN_INDEX_FLAGS) & FLAG_ENCRYPTED) != 0; String key = cursor.getString(COLUMN_INDEX_KEY);
byte[] data = cursor.getBlob(COLUMN_INDEX_DATA); byte[] metadataBytes = cursor.getBlob(COLUMN_INDEX_METADATA);
ByteArrayInputStream inputStream = new ByteArrayInputStream(data); ByteArrayInputStream inputStream = new ByteArrayInputStream(metadataBytes);
DataInputStream input = new DataInputStream(inputStream); DataInputStream input = new DataInputStream(inputStream);
if (encrypted) {
byte[] initializationVector = new byte[16];
input.readFully(initializationVector);
IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector);
try {
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new IllegalStateException(e);
}
input = new DataInputStream(new CipherInputStream(inputStream, cipher));
}
String key = input.readUTF();
DefaultContentMetadata metadata = readContentMetadata(input); DefaultContentMetadata metadata = readContentMetadata(input);
CachedContent cachedContent = new CachedContent(id, key, metadata); CachedContent cachedContent = new CachedContent(id, key, metadata);
@ -879,45 +828,19 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} }
private void deleteRow(SQLiteDatabase writableDatabase, int key) { private void deleteRow(SQLiteDatabase writableDatabase, int key) {
String[] selectionArgs = {Integer.toString(key)}; writableDatabase.delete(TABLE_NAME, WHERE_ID_EQUALS, new String[] {Integer.toString(key)});
writableDatabase.delete(TABLE_NAME, COLUMN_SELECTION_ID, selectionArgs);
} }
private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent) private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent)
throws IOException { throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
if (bufferedOutputStream == null) { writeContentMetadata(cachedContent.getMetadata(), new DataOutputStream(outputStream));
bufferedOutputStream = new ReusableBufferedOutputStream(outputStream);
} else {
bufferedOutputStream.reset(outputStream);
}
DataOutputStream output = new DataOutputStream(bufferedOutputStream);
try {
if (encrypt) {
byte[] initializationVector = new byte[16];
random.nextBytes(initializationVector);
output.write(initializationVector);
IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector);
try {
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new IllegalStateException(e); // Should never happen.
}
output.flush();
output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher));
}
output.writeUTF(cachedContent.key);
writeContentMetadata(cachedContent.getMetadata(), output);
} finally {
// Necessary to finalize the cipher.
Util.closeQuietly(output);
}
byte[] data = outputStream.toByteArray(); byte[] data = outputStream.toByteArray();
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(COLUMN_ID, cachedContent.id); values.put(COLUMN_ID, cachedContent.id);
values.put(COLUMN_FLAGS, encrypt ? FLAG_ENCRYPTED : 0); values.put(COLUMN_KEY, cachedContent.key);
values.put(COLUMN_DATA, data); values.put(COLUMN_METADATA, data);
writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values); writableDatabase.replace(TABLE_NAME, /* nullColumnHack= */ null, values);
} }
} }

View File

@ -171,7 +171,6 @@ public final class SimpleCache implements Cache {
} catch (CacheException e) { } catch (CacheException e) {
Log.e(TAG, "Storing index file failed", e); Log.e(TAG, "Storing index file failed", e);
} finally { } finally {
contentIndex.release();
unlockFolder(cacheDir); unlockFolder(cacheDir);
released = true; released = true;
} }

View File

@ -67,6 +67,16 @@ public class VersionTableTest {
assertThat(VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE)).isEqualTo(10); assertThat(VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE)).isEqualTo(10);
} }
@Test
public void removeVersion_removesSetVersion() {
VersionTable.setVersion(writableDatabase, FEATURE_OFFLINE, 1);
assertThat(VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE)).isEqualTo(1);
VersionTable.removeVersion(writableDatabase, FEATURE_OFFLINE);
assertThat(VersionTable.getVersion(readableDatabase, FEATURE_OFFLINE))
.isEqualTo(VersionTable.VERSION_UNSET);
}
@Test @Test
public void doesTableExist_nonExistingTable_returnsFalse() { public void doesTableExist_nonExistingTable_returnsFalse() {
assertThat(VersionTable.tableExists(readableDatabase, "NonExistingTable")).isFalse(); assertThat(VersionTable.tableExists(readableDatabase, "NonExistingTable")).isFalse();