blob: fa813a1cb0739ef41ed135f90a4c6f2acd4950f7 [file] [log] [blame]
/*
* 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.android.providers.media;
import static com.android.providers.media.util.DatabaseUtils.bindList;
import static com.android.providers.media.util.Logging.LOGV;
import static com.android.providers.media.util.Logging.TAG;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.mtp.MtpConstants;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.RemoteException;
import android.os.SystemClock;
import android.os.Trace;
import android.os.UserHandle;
import android.provider.MediaStore;
import android.provider.MediaStore.Audio;
import android.provider.MediaStore.Downloads;
import android.provider.MediaStore.Files.FileColumns;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.MediaColumns;
import android.provider.MediaStore.Video;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import android.util.SparseArray;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.modules.utils.BackgroundThread;
import com.android.providers.media.playlist.Playlist;
import com.android.providers.media.util.DatabaseUtils;
import com.android.providers.media.util.FileUtils;
import com.android.providers.media.util.ForegroundThread;
import com.android.providers.media.util.Logging;
import com.android.providers.media.util.MimeUtils;
import com.google.common.collect.Iterables;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.regex.Matcher;
/**
* Wrapper class for a specific database (associated with one particular
* external card, or with internal storage). Can open the actual database
* on demand, create and upgrade the schema, etc.
*/
public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable {
@VisibleForTesting
static final String TEST_RECOMPUTE_DB = "test_recompute";
@VisibleForTesting
static final String TEST_UPGRADE_DB = "test_upgrade";
@VisibleForTesting
static final String TEST_DOWNGRADE_DB = "test_downgrade";
@VisibleForTesting
public static final String TEST_CLEAN_DB = "test_clean";
static final String INTERNAL_DATABASE_NAME = "internal.db";
static final String EXTERNAL_DATABASE_NAME = "external.db";
/**
* Raw SQL clause that can be used to obtain the current generation, which
* is designed to be populated into {@link MediaColumns#GENERATION_ADDED} or
* {@link MediaColumns#GENERATION_MODIFIED}.
*/
public static final String CURRENT_GENERATION_CLAUSE = "SELECT generation FROM local_metadata";
private static final int NOTIFY_BATCH_SIZE = 256;
final Context mContext;
final String mName;
final int mVersion;
final String mVolumeName;
final boolean mEarlyUpgrade;
final boolean mLegacyProvider;
final @Nullable Class<? extends Annotation> mColumnAnnotation;
final @Nullable Class<? extends Annotation> mExportedSinceAnnotation;
final @Nullable OnSchemaChangeListener mSchemaListener;
final @Nullable OnFilesChangeListener mFilesListener;
final @Nullable OnLegacyMigrationListener mMigrationListener;
final @Nullable UnaryOperator<String> mIdGenerator;
final Set<String> mFilterVolumeNames = new ArraySet<>();
private final String mMigrationFileName;
long mScanStartTime;
long mScanStopTime;
/**
* Unfortunately we can have multiple instances of DatabaseHelper, causing
* onUpgrade() to be called multiple times if those instances happen to run in
* parallel. To prevent that, keep track of which databases we've already upgraded.
*
*/
static final Set<String> sDatabaseUpgraded = new HashSet<>();
static final Object sLock = new Object();
/**
* Lock used to guard against deadlocks in SQLite; the write lock is used to
* guard any schema changes, and the read lock is used for all other
* database operations.
* <p>
* As a concrete example: consider the case where the primary database
* connection is performing a schema change inside a transaction, while a
* secondary connection is waiting to begin a transaction. When the primary
* database connection changes the schema, it attempts to close all other
* database connections, which then deadlocks.
*/
private final ReentrantReadWriteLock mSchemaLock = new ReentrantReadWriteLock();
private static Object sMigrationLockInternal = new Object();
private static Object sMigrationLockExternal = new Object();
public interface OnSchemaChangeListener {
void onSchemaChange(@NonNull String volumeName, int versionFrom, int versionTo,
long itemCount, long durationMillis, String databaseUuid);
}
public interface OnFilesChangeListener {
void onInsert(@NonNull DatabaseHelper helper, @NonNull String volumeName, long id,
int mediaType, boolean isDownload, boolean isPending);
void onUpdate(@NonNull DatabaseHelper helper, @NonNull String volumeName,
long oldId, int oldMediaType, boolean oldIsDownload,
long newId, int newMediaType, boolean newIsDownload,
boolean oldIsTrashed, boolean newIsTrashed,
boolean oldIsPending, boolean newIsPending,
boolean oldIsFavorite, boolean newIsFavorite,
int oldSpecialFormat, int newSpecialFormat,
String oldOwnerPackage, String newOwnerPackage, String oldPath);
void onDelete(@NonNull DatabaseHelper helper, @NonNull String volumeName, long id,
int mediaType, boolean isDownload, String ownerPackage, String path);
}
public interface OnLegacyMigrationListener {
void onStarted(ContentProviderClient client, String volumeName);
void onProgress(ContentProviderClient client, String volumeName,
long progress, long total);
void onFinished(ContentProviderClient client, String volumeName);
}
public DatabaseHelper(Context context, String name,
boolean earlyUpgrade, boolean legacyProvider,
@Nullable Class<? extends Annotation> columnAnnotation,
@Nullable Class<? extends Annotation> exportedSinceAnnotation,
@Nullable OnSchemaChangeListener schemaListener,
@Nullable OnFilesChangeListener filesListener,
@NonNull OnLegacyMigrationListener migrationListener,
@Nullable UnaryOperator<String> idGenerator) {
this(context, name, getDatabaseVersion(context), earlyUpgrade, legacyProvider,
columnAnnotation, exportedSinceAnnotation, schemaListener, filesListener,
migrationListener, idGenerator);
}
public DatabaseHelper(Context context, String name, int version,
boolean earlyUpgrade, boolean legacyProvider,
@Nullable Class<? extends Annotation> columnAnnotation,
@Nullable Class<? extends Annotation> exportedSinceAnnotation,
@Nullable OnSchemaChangeListener schemaListener,
@Nullable OnFilesChangeListener filesListener,
@NonNull OnLegacyMigrationListener migrationListener,
@Nullable UnaryOperator<String> idGenerator) {
super(context, name, null, version);
mContext = context;
mName = name;
mVersion = version;
if (isInternal()) {
mVolumeName = MediaStore.VOLUME_INTERNAL;
} else if (isExternal()) {
mVolumeName = MediaStore.VOLUME_EXTERNAL;
} else {
throw new IllegalStateException("Db must be internal/external");
}
mEarlyUpgrade = earlyUpgrade;
mLegacyProvider = legacyProvider;
mColumnAnnotation = columnAnnotation;
mExportedSinceAnnotation = exportedSinceAnnotation;
mSchemaListener = schemaListener;
mFilesListener = filesListener;
mMigrationListener = migrationListener;
mIdGenerator = idGenerator;
mMigrationFileName = "." + mVolumeName;
// Configure default filters until we hear differently
if (isInternal()) {
mFilterVolumeNames.add(MediaStore.VOLUME_INTERNAL);
} else if (isExternal()) {
mFilterVolumeNames.add(MediaStore.VOLUME_EXTERNAL_PRIMARY);
}
setWriteAheadLoggingEnabled(true);
}
/**
* Configure the set of {@link MediaColumns#VOLUME_NAME} that we should use
* for filtering query results.
* <p>
* This is typically set to the list of storage volumes which are currently
* mounted, so that we don't leak cached indexed metadata from volumes which
* are currently ejected.
*/
public void setFilterVolumeNames(@NonNull Set<String> filterVolumeNames) {
synchronized (mFilterVolumeNames) {
// Skip update if identical, to help avoid database churn
if (mFilterVolumeNames.equals(filterVolumeNames)) {
return;
}
mFilterVolumeNames.clear();
mFilterVolumeNames.addAll(filterVolumeNames);
}
// Recreate all views to apply this filter
final SQLiteDatabase db = super.getWritableDatabase();
mSchemaLock.writeLock().lock();
try {
db.beginTransaction();
createLatestViews(db);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
mSchemaLock.writeLock().unlock();
}
}
@Override
public SQLiteDatabase getReadableDatabase() {
throw new UnsupportedOperationException("All database operations must be routed through"
+ " runWithTransaction() or runWithoutTransaction() to avoid deadlocks");
}
@Override
public SQLiteDatabase getWritableDatabase() {
throw new UnsupportedOperationException("All database operations must be routed through"
+ " runWithTransaction() or runWithoutTransaction() to avoid deadlocks");
}
@VisibleForTesting
SQLiteDatabase getWritableDatabaseForTest() {
// Tests rely on creating multiple instances of DatabaseHelper to test upgrade
// scenarios; so clear this state before returning databases to test.
synchronized (sLock) {
sDatabaseUpgraded.clear();
}
return super.getWritableDatabase();
}
@Override
public void onConfigure(SQLiteDatabase db) {
Log.v(TAG, "onConfigure() for " + mName);
db.setCustomScalarFunction("_INSERT", (arg) -> {
if (arg != null && mFilesListener != null
&& !mSchemaLock.isWriteLockedByCurrentThread()) {
final String[] split = arg.split(":", 5);
final String volumeName = split[0];
final long id = Long.parseLong(split[1]);
final int mediaType = Integer.parseInt(split[2]);
final boolean isDownload = Integer.parseInt(split[3]) != 0;
final boolean isPending = Integer.parseInt(split[4]) != 0;
Trace.beginSection("_INSERT");
try {
mFilesListener.onInsert(DatabaseHelper.this, volumeName, id,
mediaType, isDownload, isPending);
} finally {
Trace.endSection();
}
}
return null;
});
db.setCustomScalarFunction("_UPDATE", (arg) -> {
if (arg != null && mFilesListener != null
&& !mSchemaLock.isWriteLockedByCurrentThread()) {
final String[] split = arg.split(":", 18);
final String volumeName = split[0];
final long oldId = Long.parseLong(split[1]);
final int oldMediaType = Integer.parseInt(split[2]);
final boolean oldIsDownload = Integer.parseInt(split[3]) != 0;
final long newId = Long.parseLong(split[4]);
final int newMediaType = Integer.parseInt(split[5]);
final boolean newIsDownload = Integer.parseInt(split[6]) != 0;
final boolean oldIsTrashed = Integer.parseInt(split[7]) != 0;
final boolean newIsTrashed = Integer.parseInt(split[8]) != 0;
final boolean oldIsPending = Integer.parseInt(split[9]) != 0;
final boolean newIsPending = Integer.parseInt(split[10]) != 0;
final boolean oldIsFavorite = Integer.parseInt(split[11]) != 0;
final boolean newIsFavorite = Integer.parseInt(split[12]) != 0;
final int oldSpecialFormat = Integer.parseInt(split[13]);
final int newSpecialFormat = Integer.parseInt(split[14]);
final String oldOwnerPackage = split[15];
final String newOwnerPackage = split[16];
final String oldPath = split[17];
Trace.beginSection("_UPDATE");
try {
mFilesListener.onUpdate(DatabaseHelper.this, volumeName, oldId,
oldMediaType, oldIsDownload, newId, newMediaType, newIsDownload,
oldIsTrashed, newIsTrashed, oldIsPending, newIsPending,
oldIsFavorite, newIsFavorite, oldSpecialFormat, newSpecialFormat,
oldOwnerPackage, newOwnerPackage, oldPath);
} finally {
Trace.endSection();
}
}
return null;
});
db.setCustomScalarFunction("_DELETE", (arg) -> {
if (arg != null && mFilesListener != null
&& !mSchemaLock.isWriteLockedByCurrentThread()) {
final String[] split = arg.split(":", 6);
final String volumeName = split[0];
final long id = Long.parseLong(split[1]);
final int mediaType = Integer.parseInt(split[2]);
final boolean isDownload = Integer.parseInt(split[3]) != 0;
final String ownerPackage = split[4];
final String path = split[5];
Trace.beginSection("_DELETE");
try {
mFilesListener.onDelete(DatabaseHelper.this, volumeName, id,
mediaType, isDownload, ownerPackage, path);
} finally {
Trace.endSection();
}
}
return null;
});
db.setCustomScalarFunction("_GET_ID", (arg) -> {
if (mIdGenerator != null && !mSchemaLock.isWriteLockedByCurrentThread()) {
Trace.beginSection("_GET_ID");
try {
return mIdGenerator.apply(arg);
} finally {
Trace.endSection();
}
}
return null;
});
}
@Override
public void onCreate(final SQLiteDatabase db) {
Log.v(TAG, "onCreate() for " + mName);
mSchemaLock.writeLock().lock();
try {
updateDatabase(db, 0, mVersion);
} finally {
mSchemaLock.writeLock().unlock();
}
}
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldV, final int newV) {
Log.v(TAG, "onUpgrade() for " + mName + " from " + oldV + " to " + newV);
mSchemaLock.writeLock().lock();
try {
synchronized (sLock) {
if (sDatabaseUpgraded.contains(mName)) {
Log.v(TAG, "Skipping onUpgrade() for " + mName +
" because it was already upgraded.");
return;
} else {
sDatabaseUpgraded.add(mName);
}
}
updateDatabase(db, oldV, newV);
} finally {
mSchemaLock.writeLock().unlock();
}
}
@Override
public void onDowngrade(final SQLiteDatabase db, final int oldV, final int newV) {
Log.v(TAG, "onDowngrade() for " + mName + " from " + oldV + " to " + newV);
mSchemaLock.writeLock().lock();
try {
downgradeDatabase(db, oldV, newV);
} finally {
mSchemaLock.writeLock().unlock();
}
}
@Override
public void onOpen(final SQLiteDatabase db) {
Log.v(TAG, "onOpen() for " + mName);
tryMigrateFromLegacy(db);
}
private void tryMigrateFromLegacy(SQLiteDatabase db) {
final Object migrationLock;
if (isInternal()) {
migrationLock = sMigrationLockInternal;
} else if (isExternal()) {
migrationLock = sMigrationLockExternal;
} else {
throw new IllegalStateException("Db migration only supported for internal/external db");
}
final File migration = new File(mContext.getFilesDir(), mMigrationFileName);
// Another thread entering migration block will be blocked until the
// migration is complete from current thread.
synchronized (migrationLock) {
if (!migration.exists()) {
Log.v(TAG, "onOpen() finished for " + mName);
return;
}
mSchemaLock.writeLock().lock();
try {
// Temporarily drop indexes to improve migration performance
makePristineIndexes(db);
migrateFromLegacy(db);
createLatestIndexes(db);
} finally {
mSchemaLock.writeLock().unlock();
// Clear flag, since we should only attempt once
migration.delete();
Log.v(TAG, "onOpen() finished for " + mName);
}
}
}
@GuardedBy("mProjectionMapCache")
private final ArrayMap<Class<?>, ArrayMap<String, String>>
mProjectionMapCache = new ArrayMap<>();
/**
* Return a projection map that represents the valid columns that can be
* queried the given contract class. The mapping is built automatically
* using the {@link android.provider.Column} annotation, and is designed to
* ensure that we always support public API commitments.
*/
public ArrayMap<String, String> getProjectionMap(Class<?>... clazzes) {
ArrayMap<String, String> result = new ArrayMap<>();
synchronized (mProjectionMapCache) {
for (Class<?> clazz : clazzes) {
ArrayMap<String, String> map = mProjectionMapCache.get(clazz);
if (map == null) {
map = new ArrayMap<>();
try {
for (Field field : clazz.getFields()) {
if (Objects.equals(field.getName(), "_ID") || (mColumnAnnotation != null
&& field.isAnnotationPresent(mColumnAnnotation))) {
boolean shouldIgnoreByOsVersion = shouldBeIgnoredByOsVersion(field);
if (!shouldIgnoreByOsVersion) {
final String column = (String) field.get(null);
map.put(column, column);
}
}
}
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
mProjectionMapCache.put(clazz, map);
}
result.putAll(map);
}
return result;
}
}
/**
* Local state related to any transaction currently active on a specific
* thread, such as collecting the set of {@link Uri} that should be notified
* upon transaction success.
* <p>
* We suppress Error Prone here because there are multiple
* {@link DatabaseHelper} instances within the process, and state needs to
* be tracked uniquely per-helper.
*/
@SuppressWarnings("ThreadLocalUsage")
private final ThreadLocal<TransactionState> mTransactionState = new ThreadLocal<>();
private static class TransactionState {
/**
* Flag indicating if this transaction has been marked as being
* successful.
*/
public boolean successful;
/**
* List of tasks that should be executed in a blocking fashion when this
* transaction has been successfully finished.
*/
public final ArrayList<Runnable> blockingTasks = new ArrayList<>();
/**
* Map from {@code flags} value to set of {@link Uri} that would have
* been sent directly via {@link ContentResolver#notifyChange}, but are
* instead being collected due to this ongoing transaction.
*/
public final SparseArray<ArraySet<Uri>> notifyChanges = new SparseArray<>();
/**
* List of tasks that should be enqueued onto {@link BackgroundThread}
* after any {@link #notifyChanges} have been dispatched. We keep this
* as a separate pass to ensure that we don't risk running in parallel
* with other more important tasks.
*/
public final ArrayList<Runnable> backgroundTasks = new ArrayList<>();
}
public boolean isTransactionActive() {
return (mTransactionState.get() != null);
}
public void beginTransaction() {
Trace.beginSection("transaction " + getDatabaseName());
Trace.beginSection("beginTransaction");
try {
beginTransactionInternal();
} finally {
Trace.endSection();
}
}
private void beginTransactionInternal() {
if (mTransactionState.get() != null) {
throw new IllegalStateException("Nested transactions not supported");
}
mTransactionState.set(new TransactionState());
final SQLiteDatabase db = super.getWritableDatabase();
mSchemaLock.readLock().lock();
db.beginTransaction();
db.execSQL("UPDATE local_metadata SET generation=generation+1;");
}
public void setTransactionSuccessful() {
final TransactionState state = mTransactionState.get();
if (state == null) {
throw new IllegalStateException("No transaction in progress");
}
state.successful = true;
final SQLiteDatabase db = super.getWritableDatabase();
db.setTransactionSuccessful();
}
public void endTransaction() {
Trace.beginSection("endTransaction");
try {
endTransactionInternal();
} finally {
Trace.endSection();
Trace.endSection();
}
}
private void endTransactionInternal() {
final TransactionState state = mTransactionState.get();
if (state == null) {
throw new IllegalStateException("No transaction in progress");
}
mTransactionState.remove();
final SQLiteDatabase db = super.getWritableDatabase();
db.endTransaction();
mSchemaLock.readLock().unlock();
if (state.successful) {
for (int i = 0; i < state.blockingTasks.size(); i++) {
state.blockingTasks.get(i).run();
}
// We carefully "phase" our two sets of work here to ensure that we
// completely finish dispatching all change notifications before we
// process background tasks, to ensure that the background work
// doesn't steal resources from the more important foreground work
ForegroundThread.getExecutor().execute(() -> {
for (int i = 0; i < state.notifyChanges.size(); i++) {
notifyChangeInternal(state.notifyChanges.valueAt(i),
state.notifyChanges.keyAt(i));
}
// Now that we've finished with all our important work, we can
// finally kick off any internal background tasks
for (int i = 0; i < state.backgroundTasks.size(); i++) {
BackgroundThread.getExecutor().execute(state.backgroundTasks.get(i));
}
});
}
}
/**
* Execute the given operation inside a transaction. If the calling thread
* is not already in an active transaction, this method will wrap the given
* runnable inside a new transaction.
*/
public @NonNull <T> T runWithTransaction(@NonNull Function<SQLiteDatabase, T> op) {
// We carefully acquire the database here so that any schema changes can
// be applied before acquiring the read lock below
final SQLiteDatabase db = super.getWritableDatabase();
if (mTransactionState.get() != null) {
// Already inside a transaction, so we can run directly
return op.apply(db);
} else {
// Not inside a transaction, so we need to make one
beginTransaction();
try {
final T res = op.apply(db);
setTransactionSuccessful();
return res;
} finally {
endTransaction();
}
}
}
/**
* Execute the given operation regardless of the calling thread being in an
* active transaction or not.
*/
public @NonNull <T> T runWithoutTransaction(@NonNull Function<SQLiteDatabase, T> op) {
// We carefully acquire the database here so that any schema changes can
// be applied before acquiring the read lock below
final SQLiteDatabase db = super.getWritableDatabase();
if (mTransactionState.get() != null) {
// Already inside a transaction, so we can run directly
return op.apply(db);
} else {
// We still need to acquire a schema read lock
mSchemaLock.readLock().lock();
try {
return op.apply(db);
} finally {
mSchemaLock.readLock().unlock();
}
}
}
public void notifyInsert(@NonNull Uri uri) {
notifyChange(uri, ContentResolver.NOTIFY_INSERT);
}
public void notifyUpdate(@NonNull Uri uri) {
notifyChange(uri, ContentResolver.NOTIFY_UPDATE);
}
public void notifyDelete(@NonNull Uri uri) {
notifyChange(uri, ContentResolver.NOTIFY_DELETE);
}
/**
* Notify that the given {@link Uri} has changed. This enqueues the
* notification if currently inside a transaction, and they'll be
* clustered and sent when the transaction completes.
*/
public void notifyChange(@NonNull Uri uri, int flags) {
if (LOGV) Log.v(TAG, "Notifying " + uri);
final TransactionState state = mTransactionState.get();
if (state != null) {
ArraySet<Uri> set = state.notifyChanges.get(flags);
if (set == null) {
set = new ArraySet<>();
state.notifyChanges.put(flags, set);
}
set.add(uri);
} else {
ForegroundThread.getExecutor().execute(() -> {
notifySingleChangeInternal(uri, flags);
});
}
}
private void notifySingleChangeInternal(@NonNull Uri uri, int flags) {
Trace.beginSection("notifySingleChange");
try {
mContext.getContentResolver().notifyChange(uri, null, flags);
} finally {
Trace.endSection();
}
}
private void notifyChangeInternal(@NonNull Collection<Uri> uris, int flags) {
Trace.beginSection("notifyChange");
try {
for (List<Uri> partition : Iterables.partition(uris, NOTIFY_BATCH_SIZE)) {
mContext.getContentResolver().notifyChange(partition, null, flags);
}
} finally {
Trace.endSection();
}
}
/**
* Post the given task to be run in a blocking fashion after any current
* transaction has finished. If there is no active transaction, the task is
* immediately executed.
*/
public void postBlocking(@NonNull Runnable command) {
final TransactionState state = mTransactionState.get();
if (state != null) {
state.blockingTasks.add(command);
} else {
command.run();
}
}
/**
* Post the given task to be run in background after any current transaction
* has finished. If there is no active transaction, the task is immediately
* dispatched to run in the background.
*/
public void postBackground(@NonNull Runnable command) {
final TransactionState state = mTransactionState.get();
if (state != null) {
state.backgroundTasks.add(command);
} else {
BackgroundThread.getExecutor().execute(command);
}
}
/**
* This method cleans up any files created by android.media.MiniThumbFile, removed after P.
* It's triggered during database update only, in order to run only once.
*/
private static void deleteLegacyThumbnailData() {
File directory = new File(Environment.getExternalStorageDirectory(), "/DCIM/.thumbnails");
final FilenameFilter filter = (dir, filename) -> filename.startsWith(".thumbdata");
final File[] files = directory.listFiles(filter);
for (File f : (files != null) ? files : new File[0]) {
if (!f.delete()) {
Log.e(TAG, "Failed to delete legacy thumbnail data " + f.getAbsolutePath());
}
}
}
@Deprecated
public static int getDatabaseVersion(Context context) {
// We now use static versions defined internally instead of the
// versionCode from the manifest
return VERSION_LATEST;
}
@VisibleForTesting
static void makePristineSchema(SQLiteDatabase db) {
// We are dropping all tables and recreating new schema. This
// is a clear indication of major change in MediaStore version.
// Hence reset the Uuid whenever we change the schema.
resetAndGetUuid(db);
// drop all triggers
Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'trigger'",
null, null, null, null);
while (c.moveToNext()) {
if (c.getString(0).startsWith("sqlite_")) continue;
db.execSQL("DROP TRIGGER IF EXISTS " + c.getString(0));
}
c.close();
// drop all views
c = db.query("sqlite_master", new String[] {"name"}, "type is 'view'",
null, null, null, null);
while (c.moveToNext()) {
if (c.getString(0).startsWith("sqlite_")) continue;
db.execSQL("DROP VIEW IF EXISTS " + c.getString(0));
}
c.close();
// drop all indexes
c = db.query("sqlite_master", new String[] {"name"}, "type is 'index'",
null, null, null, null);
while (c.moveToNext()) {
if (c.getString(0).startsWith("sqlite_")) continue;
db.execSQL("DROP INDEX IF EXISTS " + c.getString(0));
}
c.close();
// drop all tables
c = db.query("sqlite_master", new String[] {"name"}, "type is 'table'",
null, null, null, null);
while (c.moveToNext()) {
if (c.getString(0).startsWith("sqlite_")) continue;
db.execSQL("DROP TABLE IF EXISTS " + c.getString(0));
}
c.close();
}
private void createLatestSchema(SQLiteDatabase db) {
// We're about to start all ID numbering from scratch, so revoke any
// outstanding permission grants to ensure we don't leak data
try {
final PackageInfo pkg = mContext.getPackageManager().getPackageInfo(
mContext.getPackageName(), PackageManager.GET_PROVIDERS);
if (pkg != null && pkg.providers != null) {
for (ProviderInfo provider : pkg.providers) {
mContext.revokeUriPermission(Uri.parse("content://" + provider.authority),
Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
}
} catch (Exception e) {
Log.w(TAG, "Failed to revoke permissions", e);
}
makePristineSchema(db);
db.execSQL("CREATE TABLE local_metadata (generation INTEGER DEFAULT 0)");
db.execSQL("INSERT INTO local_metadata VALUES (0)");
db.execSQL("CREATE TABLE android_metadata (locale TEXT)");
db.execSQL("CREATE TABLE thumbnails (_id INTEGER PRIMARY KEY,_data TEXT,image_id INTEGER,"
+ "kind INTEGER,width INTEGER,height INTEGER)");
db.execSQL("CREATE TABLE album_art (album_id INTEGER PRIMARY KEY,_data TEXT)");
db.execSQL("CREATE TABLE videothumbnails (_id INTEGER PRIMARY KEY,_data TEXT,"
+ "video_id INTEGER,kind INTEGER,width INTEGER,height INTEGER)");
db.execSQL("CREATE TABLE files (_id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "_data TEXT UNIQUE COLLATE NOCASE,_size INTEGER,format INTEGER,parent INTEGER,"
+ "date_added INTEGER,date_modified INTEGER,mime_type TEXT,title TEXT,"
+ "description TEXT,_display_name TEXT,picasa_id TEXT,orientation INTEGER,"
+ "latitude DOUBLE,longitude DOUBLE,datetaken INTEGER,mini_thumb_magic INTEGER,"
+ "bucket_id TEXT,bucket_display_name TEXT,isprivate INTEGER,title_key TEXT,"
+ "artist_id INTEGER,album_id INTEGER,composer TEXT,track INTEGER,"
+ "year INTEGER CHECK(year!=0),is_ringtone INTEGER,is_music INTEGER,"
+ "is_alarm INTEGER,is_notification INTEGER,is_podcast INTEGER,album_artist TEXT,"
+ "duration INTEGER,bookmark INTEGER,artist TEXT,album TEXT,resolution TEXT,"
+ "tags TEXT,category TEXT,language TEXT,mini_thumb_data TEXT,name TEXT,"
+ "media_type INTEGER,old_id INTEGER,is_drm INTEGER,"
+ "width INTEGER, height INTEGER, title_resource_uri TEXT,"
+ "owner_package_name TEXT DEFAULT NULL,"
+ "color_standard INTEGER, color_transfer INTEGER, color_range INTEGER,"
+ "_hash BLOB DEFAULT NULL, is_pending INTEGER DEFAULT 0,"
+ "is_download INTEGER DEFAULT 0, download_uri TEXT DEFAULT NULL,"
+ "referer_uri TEXT DEFAULT NULL, is_audiobook INTEGER DEFAULT 0,"
+ "date_expires INTEGER DEFAULT NULL,is_trashed INTEGER DEFAULT 0,"
+ "group_id INTEGER DEFAULT NULL,primary_directory TEXT DEFAULT NULL,"
+ "secondary_directory TEXT DEFAULT NULL,document_id TEXT DEFAULT NULL,"
+ "instance_id TEXT DEFAULT NULL,original_document_id TEXT DEFAULT NULL,"
+ "relative_path TEXT DEFAULT NULL,volume_name TEXT DEFAULT NULL,"
+ "artist_key TEXT DEFAULT NULL,album_key TEXT DEFAULT NULL,"
+ "genre TEXT DEFAULT NULL,genre_key TEXT DEFAULT NULL,genre_id INTEGER,"
+ "author TEXT DEFAULT NULL, bitrate INTEGER DEFAULT NULL,"
+ "capture_framerate REAL DEFAULT NULL, cd_track_number TEXT DEFAULT NULL,"
+ "compilation INTEGER DEFAULT NULL, disc_number TEXT DEFAULT NULL,"
+ "is_favorite INTEGER DEFAULT 0, num_tracks INTEGER DEFAULT NULL,"
+ "writer TEXT DEFAULT NULL, exposure_time TEXT DEFAULT NULL,"
+ "f_number TEXT DEFAULT NULL, iso INTEGER DEFAULT NULL,"
+ "scene_capture_type INTEGER DEFAULT NULL, generation_added INTEGER DEFAULT 0,"
+ "generation_modified INTEGER DEFAULT 0, xmp BLOB DEFAULT NULL,"
+ "_transcode_status INTEGER DEFAULT 0, _video_codec_type TEXT DEFAULT NULL,"
+ "_modifier INTEGER DEFAULT 0, is_recording INTEGER DEFAULT 0,"
+ "redacted_uri_id TEXT DEFAULT NULL, _user_id INTEGER DEFAULT "
+ UserHandle.myUserId() + ", _special_format INTEGER DEFAULT NULL)");
db.execSQL("CREATE TABLE log (time DATETIME, message TEXT)");
db.execSQL("CREATE TABLE deleted_media (_id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "old_id INTEGER UNIQUE, generation_modified INTEGER NOT NULL)");
if (isExternal()) {
db.execSQL("CREATE TABLE audio_playlists_map (_id INTEGER PRIMARY KEY,"
+ "audio_id INTEGER NOT NULL,playlist_id INTEGER NOT NULL,"
+ "play_order INTEGER NOT NULL)");
}
createLatestViews(db);
createLatestTriggers(db);
createLatestIndexes(db);
// Since this code is used by both the legacy and modern providers, we
// only want to migrate when we're running as the modern provider
if (!mLegacyProvider) {
try {
new File(mContext.getFilesDir(), mMigrationFileName).createNewFile();
} catch (IOException e) {
Log.e(TAG, "Failed to create a migration file: ." + mVolumeName, e);
}
}
}
/**
* Migrate important information from {@link MediaStore#AUTHORITY_LEGACY},
* if present on this device. We only do this once during early database
* creation, to help us preserve information like {@link MediaColumns#_ID}
* and {@link MediaColumns#IS_FAVORITE}.
*/
private void migrateFromLegacy(SQLiteDatabase db) {
// TODO: focus this migration on secondary volumes once we have separate
// databases for each volume; for now only migrate primary storage
try (ContentProviderClient client = mContext.getContentResolver()
.acquireContentProviderClient(MediaStore.AUTHORITY_LEGACY)) {
if (client == null) {
Log.d(TAG, "No legacy provider available for migration");
return;
}
final Uri queryUri = MediaStore
.rewriteToLegacy(MediaStore.Files.getContentUri(mVolumeName));
final Bundle extras = new Bundle();
extras.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
extras.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
extras.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE);
db.beginTransaction();
Log.d(TAG, "Starting migration from legacy provider");
if (mMigrationListener != null) {
mMigrationListener.onStarted(client, mVolumeName);
}
try (Cursor c = client.query(queryUri, sMigrateColumns.toArray(new String[0]),
extras, null)) {
final ContentValues values = new ContentValues();
while (c.moveToNext()) {
values.clear();
// Start by deriving all values from migrated data column,
// then overwrite with other migrated columns
final String data = c.getString(c.getColumnIndex(MediaColumns.DATA));
values.put(MediaColumns.DATA, data);
FileUtils.computeValuesFromData(values, /*isForFuse*/ false);
final String volumeNameFromPath = values.getAsString(MediaColumns.VOLUME_NAME);
for (String column : sMigrateColumns) {
DatabaseUtils.copyFromCursorToContentValues(column, c, values);
}
final String volumeNameMigrated = values.getAsString(MediaColumns.VOLUME_NAME);
// While upgrading from P OS or below, VOLUME_NAME can be NULL in legacy
// database. When VOLUME_NAME is NULL, extract VOLUME_NAME from
// MediaColumns.DATA
if (volumeNameMigrated == null || volumeNameMigrated.isEmpty()) {
values.put(MediaColumns.VOLUME_NAME, volumeNameFromPath);
}
final String volumePath = FileUtils.extractVolumePath(data);
// Handle playlist files which may need special handling if
// there are no "real" playlist files.
final int mediaType = c.getInt(c.getColumnIndex(FileColumns.MEDIA_TYPE));
if (isExternal() && volumePath != null &&
mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) {
File playlistFile = new File(data);
if (!playlistFile.exists()) {
if (LOGV) Log.v(TAG, "Migrating playlist file " + playlistFile);
// Migrate virtual playlists to a "real" playlist file.
// Also change playlist file name and path to adapt to new
// default primary directory.
String playlistFilePath = data;
try {
playlistFilePath = migratePlaylistFiles(client,
c.getLong(c.getColumnIndex(FileColumns._ID)));
// Either migration didn't happen or is not necessary because
// playlist file already exists
if (playlistFilePath == null) playlistFilePath = data;
} catch (Exception e) {
// We only have one shot to migrate data, so log and
// keep marching forward.
Log.w(TAG, "Couldn't migrate playlist file " + data);
}
values.put(FileColumns.DATA, playlistFilePath);
FileUtils.computeValuesFromData(values, /*isForFuse*/ false);
}
}
// When migrating pending or trashed files, we might need to
// rename them on disk to match new schema
if (volumePath != null) {
final String oldData = values.getAsString(MediaColumns.DATA);
FileUtils.computeDataFromValues(values, new File(volumePath),
/*isForFuse*/ false);
final String recomputedData = values.getAsString(MediaColumns.DATA);
if (!Objects.equals(oldData, recomputedData)) {
try {
renameWithRetry(oldData, recomputedData);
} catch (IOException e) {
// We only have one shot to migrate data, so log and
// keep marching forward
Log.w(TAG, "Failed to rename " + values + "; continuing", e);
FileUtils.computeValuesFromData(values, /*isForFuse*/ false);
}
}
}
if (db.insert("files", null, values) == -1) {
// We only have one shot to migrate data, so log and
// keep marching forward
Log.w(TAG, "Failed to insert " + values + "; continuing");
}
// To avoid SQLITE_NOMEM errors, we need to periodically
// flush the current transaction and start another one
if ((c.getPosition() % 2_000) == 0) {
db.setTransactionSuccessful();
db.endTransaction();
db.beginTransaction();
// And announce that we're actively making progress
final int progress = c.getPosition();
final int total = c.getCount();
Log.v(TAG, "Migrated " + progress + " of " + total + "...");
if (mMigrationListener != null) {
mMigrationListener.onProgress(client, mVolumeName, progress, total);
}
}
}
Log.d(TAG, "Finished migration from legacy provider");
} catch (Exception e) {
// We have to guard ourselves against any weird behavior of the
// legacy provider by trying to catch everything
Log.w(TAG, "Failed migration from legacy provider", e);
}
// We tried our best above to migrate everything we could, and we
// only have one possible shot, so mark everything successful
db.setTransactionSuccessful();
db.endTransaction();
if (mMigrationListener != null) {
mMigrationListener.onFinished(client, mVolumeName);
}
}
}
@Nullable
private String migratePlaylistFiles(ContentProviderClient client, long playlistId)
throws IllegalStateException {
final String selection = FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_PLAYLIST
+ " AND " + FileColumns._ID + "=" + playlistId;
final String[] projection = new String[]{
FileColumns._ID,
FileColumns.DATA,
MediaColumns.MIME_TYPE,
MediaStore.Audio.PlaylistsColumns.NAME,
};
final Uri queryUri = MediaStore
.rewriteToLegacy(MediaStore.Files.getContentUri(mVolumeName));
try (Cursor cursor = client.query(queryUri, projection, selection, null, null)) {
if (!cursor.moveToFirst()) {
throw new IllegalStateException("Couldn't find database row for playlist file"
+ playlistId);
}
final String data = cursor.getString(cursor.getColumnIndex(MediaColumns.DATA));
File playlistFile = new File(data);
if (playlistFile.exists()) {
throw new IllegalStateException("Playlist file exists " + data);
}
String mimeType = cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE));
// Sometimes, playlists in Q may have mimeType as
// "application/octet-stream". Ensure that playlist rows have the
// right playlist mimeType. These rows will be committed to a file
// and hence they should have correct playlist mimeType for
// Playlist#write to identify the right child playlist class.
if (!MimeUtils.isPlaylistMimeType(mimeType)) {
// Playlist files should always have right mimeType, default to
// audio/mpegurl when mimeType doesn't match playlist media_type.
mimeType = "audio/mpegurl";
}
// If the directory is Playlists/ change the directory to Music/
// since defaultPrimary for playlists is Music/. This helps
// resolve any future app-compat issues around renaming playlist
// files.
File parentFile = playlistFile.getParentFile();
if (parentFile.getName().equalsIgnoreCase("Playlists")) {
parentFile = new File(parentFile.getParentFile(), Environment.DIRECTORY_MUSIC);
}
final String playlistName = cursor.getString(
cursor.getColumnIndex(MediaStore.Audio.PlaylistsColumns.NAME));
try {
// Build playlist file path with a file extension that matches
// playlist mimeType.
playlistFile = FileUtils.buildUniqueFile(parentFile, mimeType, playlistName);
} catch(FileNotFoundException e) {
Log.e(TAG, "Couldn't create unique file for " + playlistFile +
", using actual playlist file name", e);
}
final long rowId = cursor.getLong(cursor.getColumnIndex(FileColumns._ID));
final Uri playlistMemberUri = MediaStore.rewriteToLegacy(
MediaStore.Audio.Playlists.Members.getContentUri(mVolumeName, rowId));
createPlaylistFile(client, playlistMemberUri, playlistFile);
return playlistFile.getAbsolutePath();
} catch (RemoteException e) {
throw new IllegalStateException(e);
}
}
/**
* Creates "real" playlist files on disk from the playlist data from the database.
*/
private void createPlaylistFile(ContentProviderClient client, @NonNull Uri playlistMemberUri,
@NonNull File playlistFile) throws IllegalStateException {
final String[] projection = new String[] {
MediaStore.Audio.Playlists.Members.AUDIO_ID,
MediaStore.Audio.Playlists.Members.PLAY_ORDER,
};
final Playlist playlist = new Playlist();
// Migrating music->playlist association.
try (Cursor c = client.query(playlistMemberUri, projection, null, null,
Audio.Playlists.Members.DEFAULT_SORT_ORDER)) {
while (c.moveToNext()) {
// Write these values to the playlist file
final long audioId = c.getLong(0);
final int playOrder = c.getInt(1);
final Uri audioFileUri = MediaStore.rewriteToLegacy(ContentUris.withAppendedId(
MediaStore.Files.getContentUri(mVolumeName), audioId));
final String audioFilePath = queryForData(client, audioFileUri);
if (audioFilePath == null) {
// This shouldn't happen, we should always find audio file
// unless audio file is removed, and database has stale db
// row. However this shouldn't block creating playlist
// files;
Log.e(TAG, "Couldn't find audio file for " + audioId + ", continuing..");
continue;
}
playlist.add(playOrder, playlistFile.toPath().getParent().
relativize(new File(audioFilePath).toPath()));
}
try {
writeToPlaylistFileWithRetry(playlistFile, playlist);
} catch (IOException e) {
// We only have one shot to migrate data, so log and
// keep marching forward.
Log.w(TAG, "Couldn't migrate playlist file " + playlistFile);
}
} catch (RemoteException e) {
throw new IllegalStateException(e);
}
}
/**
* Return the {@link MediaColumns#DATA} field for the given {@code uri}.
*/
private String queryForData(ContentProviderClient client, @NonNull Uri uri) {
try (Cursor c = client.query(uri, new String[] {FileColumns.DATA}, Bundle.EMPTY, null)) {
if (c.moveToFirst()) {
return c.getString(0);
}
} catch (Exception e) {
Log.w(TAG, "Exception occurred while querying for data file for " + uri, e);
}
return null;
}
/**
* Set of columns that should be migrated from the legacy provider,
* including core information to identify each media item, followed by
* columns that can be edited by users. (We omit columns here that are
* marked as "readOnly" in the {@link MediaStore} annotations, since those
* will be regenerated by the first scan after upgrade.)
*/
private static final ArraySet<String> sMigrateColumns = new ArraySet<>();
{
sMigrateColumns.add(MediaStore.MediaColumns._ID);
sMigrateColumns.add(MediaStore.MediaColumns.DATA);
sMigrateColumns.add(MediaStore.MediaColumns.VOLUME_NAME);
sMigrateColumns.add(MediaStore.Files.FileColumns.MEDIA_TYPE);
sMigrateColumns.add(MediaStore.MediaColumns.DATE_ADDED);
sMigrateColumns.add(MediaStore.MediaColumns.DATE_EXPIRES);
sMigrateColumns.add(MediaStore.MediaColumns.IS_PENDING);
sMigrateColumns.add(MediaStore.MediaColumns.IS_TRASHED);
sMigrateColumns.add(MediaStore.MediaColumns.IS_FAVORITE);
sMigrateColumns.add(MediaStore.MediaColumns.OWNER_PACKAGE_NAME);
sMigrateColumns.add(MediaStore.MediaColumns.ORIENTATION);
sMigrateColumns.add(MediaStore.Files.FileColumns.PARENT);
sMigrateColumns.add(MediaStore.Audio.AudioColumns.BOOKMARK);
sMigrateColumns.add(MediaStore.Video.VideoColumns.TAGS);
sMigrateColumns.add(MediaStore.Video.VideoColumns.CATEGORY);
sMigrateColumns.add(MediaStore.Video.VideoColumns.BOOKMARK);
// This also migrates MediaStore.Images.ImageColumns.IS_PRIVATE
// as they both have the same value "isprivate".
sMigrateColumns.add(MediaStore.Video.VideoColumns.IS_PRIVATE);
sMigrateColumns.add(MediaStore.DownloadColumns.DOWNLOAD_URI);
sMigrateColumns.add(MediaStore.DownloadColumns.REFERER_URI);
}
private static void makePristineViews(SQLiteDatabase db) {
// drop all views
Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'view'",
null, null, null, null);
while (c.moveToNext()) {
db.execSQL("DROP VIEW IF EXISTS " + c.getString(0));
}
c.close();
}
private void createLatestViews(SQLiteDatabase db) {
makePristineViews(db);
if (mColumnAnnotation == null) {
Log.w(TAG, "No column annotation provided; not creating views");
return;
}
final String filterVolumeNames;
synchronized (mFilterVolumeNames) {
filterVolumeNames = bindList(mFilterVolumeNames.toArray());
}
if (isExternal()) {
db.execSQL("CREATE VIEW audio_playlists AS SELECT "
+ getColumnsForCollection(Audio.Playlists.class)
+ " FROM files WHERE media_type=4");
}
db.execSQL("CREATE VIEW searchhelpertitle AS SELECT * FROM audio ORDER BY title_key");
db.execSQL("CREATE VIEW search AS SELECT _id,'artist' AS mime_type,artist,NULL AS album,"
+ "NULL AS title,artist AS text1,NULL AS text2,number_of_albums AS data1,"
+ "number_of_tracks AS data2,artist_key AS match,"
+ "'content://media/external/audio/artists/'||_id AS suggest_intent_data,"
+ "1 AS grouporder FROM artist_info WHERE (artist!='<unknown>')"
+ " UNION ALL SELECT _id,'album' AS mime_type,artist,album,"
+ "NULL AS title,album AS text1,artist AS text2,NULL AS data1,"
+ "NULL AS data2,artist_key||' '||album_key AS match,"
+ "'content://media/external/audio/albums/'||_id AS suggest_intent_data,"
+ "2 AS grouporder FROM album_info"
+ " WHERE (album!='<unknown>')"
+ " UNION ALL SELECT searchhelpertitle._id AS _id,mime_type,artist,album,title,"
+ "title AS text1,artist AS text2,NULL AS data1,"
+ "NULL AS data2,artist_key||' '||album_key||' '||title_key AS match,"
+ "'content://media/external/audio/media/'||searchhelpertitle._id"
+ " AS suggest_intent_data,"
+ "3 AS grouporder FROM searchhelpertitle WHERE (title != '')");
db.execSQL("CREATE VIEW audio AS SELECT "
+ getColumnsForCollection(Audio.Media.class)
+ " FROM files WHERE media_type=2");
db.execSQL("CREATE VIEW video AS SELECT "
+ getColumnsForCollection(Video.Media.class)
+ " FROM files WHERE media_type=3");
db.execSQL("CREATE VIEW images AS SELECT "
+ getColumnsForCollection(Images.Media.class)
+ " FROM files WHERE media_type=1");
db.execSQL("CREATE VIEW downloads AS SELECT "
+ getColumnsForCollection(Downloads.class)
+ " FROM files WHERE is_download=1");
db.execSQL("CREATE VIEW audio_artists AS SELECT "
+ " artist_id AS " + Audio.Artists._ID
+ ", MIN(artist) AS " + Audio.Artists.ARTIST
+ ", artist_key AS " + Audio.Artists.ARTIST_KEY
+ ", COUNT(DISTINCT album_id) AS " + Audio.Artists.NUMBER_OF_ALBUMS
+ ", COUNT(DISTINCT _id) AS " + Audio.Artists.NUMBER_OF_TRACKS
+ " FROM audio"
+ " WHERE is_music=1 AND is_pending=0 AND is_trashed=0"
+ " AND volume_name IN " + filterVolumeNames
+ " GROUP BY artist_id");
db.execSQL("CREATE VIEW audio_artists_albums AS SELECT "
+ " album_id AS " + Audio.Albums._ID
+ ", album_id AS " + Audio.Albums.ALBUM_ID
+ ", MIN(album) AS " + Audio.Albums.ALBUM
+ ", album_key AS " + Audio.Albums.ALBUM_KEY
+ ", artist_id AS " + Audio.Albums.ARTIST_ID
+ ", artist AS " + Audio.Albums.ARTIST
+ ", artist_key AS " + Audio.Albums.ARTIST_KEY
+ ", (SELECT COUNT(*) FROM audio WHERE " + Audio.Albums.ALBUM_ID
+ " = TEMP.album_id) AS " + Audio.Albums.NUMBER_OF_SONGS
+ ", COUNT(DISTINCT _id) AS " + Audio.Albums.NUMBER_OF_SONGS_FOR_ARTIST
+ ", MIN(year) AS " + Audio.Albums.FIRST_YEAR
+ ", MAX(year) AS " + Audio.Albums.LAST_YEAR
+ ", NULL AS " + Audio.Albums.ALBUM_ART
+ " FROM audio TEMP"
+ " WHERE is_music=1 AND is_pending=0 AND is_trashed=0"
+ " AND volume_name IN " + filterVolumeNames
+ " GROUP BY album_id, artist_id");
db.execSQL("CREATE VIEW audio_albums AS SELECT "
+ " album_id AS " + Audio.Albums._ID
+ ", album_id AS " + Audio.Albums.ALBUM_ID
+ ", MIN(album) AS " + Audio.Albums.ALBUM
+ ", album_key AS " + Audio.Albums.ALBUM_KEY
+ ", artist_id AS " + Audio.Albums.ARTIST_ID
+ ", artist AS " + Audio.Albums.ARTIST
+ ", artist_key AS " + Audio.Albums.ARTIST_KEY
+ ", COUNT(DISTINCT _id) AS " + Audio.Albums.NUMBER_OF_SONGS
+ ", COUNT(DISTINCT _id) AS " + Audio.Albums.NUMBER_OF_SONGS_FOR_ARTIST
+ ", MIN(year) AS " + Audio.Albums.FIRST_YEAR
+ ", MAX(year) AS " + Audio.Albums.LAST_YEAR
+ ", NULL AS " + Audio.Albums.ALBUM_ART
+ " FROM audio"
+ " WHERE is_music=1 AND is_pending=0 AND is_trashed=0"
+ " AND volume_name IN " + filterVolumeNames
+ " GROUP BY album_id");
db.execSQL("CREATE VIEW audio_genres AS SELECT "
+ " genre_id AS " + Audio.Genres._ID
+ ", MIN(genre) AS " + Audio.Genres.NAME
+ " FROM audio"
+ " WHERE is_pending=0 AND is_trashed=0 AND volume_name IN " + filterVolumeNames
+ " GROUP BY genre_id");
}
private String getColumnsForCollection(Class<?> collection) {
return String.join(",", getProjectionMap(collection).keySet()) + ",_modifier";
}
private static void makePristineTriggers(SQLiteDatabase db) {
// drop all triggers
Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'trigger'",
null, null, null, null);
while (c.moveToNext()) {
if (c.getString(0).startsWith("sqlite_")) continue;
db.execSQL("DROP TRIGGER IF EXISTS " + c.getString(0));
}
c.close();
}
private static void createLatestTriggers(SQLiteDatabase db) {
makePristineTriggers(db);
final String insertArg =
"new.volume_name||':'||new._id||':'||new.media_type||':'||new.is_download"
+ "||':'||new.is_pending";
final String updateArg =
"old.volume_name||':'||old._id||':'||old.media_type||':'||old.is_download"
+ "||':'||new._id||':'||new.media_type||':'||new.is_download"
+ "||':'||old.is_trashed||':'||new.is_trashed"
+ "||':'||old.is_pending||':'||new.is_pending"
+ "||':'||old.is_favorite||':'||new.is_favorite"
+ "||':'||ifnull(old._special_format,0)"
+ "||':'||ifnull(new._special_format,0)"
+ "||':'||ifnull(old.owner_package_name,'null')"
+ "||':'||ifnull(new.owner_package_name,'null')||':'||old._data";
final String deleteArg =
"old.volume_name||':'||old._id||':'||old.media_type||':'||old.is_download"
+ "||':'||ifnull(old.owner_package_name,'null')||':'||old._data";
db.execSQL("CREATE TRIGGER files_insert AFTER INSERT ON files"
+ " BEGIN SELECT _INSERT(" + insertArg + "); END");
db.execSQL("CREATE TRIGGER files_update AFTER UPDATE ON files"
+ " BEGIN SELECT _UPDATE(" + updateArg + "); END");
db.execSQL("CREATE TRIGGER files_delete AFTER DELETE ON files"
+ " BEGIN SELECT _DELETE(" + deleteArg + "); END");
}
private static void makePristineIndexes(SQLiteDatabase db) {
// drop all indexes
Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'index'",
null, null, null, null);
while (c.moveToNext()) {
if (c.getString(0).startsWith("sqlite_")) continue;
db.execSQL("DROP INDEX IF EXISTS " + c.getString(0));
}
c.close();
}
private static void createLatestIndexes(SQLiteDatabase db) {
makePristineIndexes(db);
db.execSQL("CREATE INDEX image_id_index on thumbnails(image_id)");
db.execSQL("CREATE INDEX video_id_index on videothumbnails(video_id)");
db.execSQL("CREATE INDEX album_id_idx ON files(album_id)");
db.execSQL("CREATE INDEX artist_id_idx ON files(artist_id)");
db.execSQL("CREATE INDEX genre_id_idx ON files(genre_id)");
db.execSQL("CREATE INDEX bucket_index on files(bucket_id,media_type,datetaken, _id)");
db.execSQL("CREATE INDEX bucket_name on files(bucket_id,media_type,bucket_display_name)");
db.execSQL("CREATE INDEX format_index ON files(format)");
db.execSQL("CREATE INDEX media_type_index ON files(media_type)");
db.execSQL("CREATE INDEX parent_index ON files(parent)");
db.execSQL("CREATE INDEX path_index ON files(_data)");
db.execSQL("CREATE INDEX sort_index ON files(datetaken ASC, _id ASC)");
db.execSQL("CREATE INDEX title_idx ON files(title)");
db.execSQL("CREATE INDEX titlekey_index ON files(title_key)");
}
private static void updateCollationKeys(SQLiteDatabase db) {
// Delete albums and artists, then clear the modification time on songs, which
// will cause the media scanner to rescan everything, rebuilding the artist and
// album tables along the way, while preserving playlists.
// We need this rescan because ICU also changed, and now generates different
// collation keys
db.execSQL("DELETE from albums");
db.execSQL("DELETE from artists");
db.execSQL("UPDATE files SET date_modified=0;");
}
private static void updateAddTitleResource(SQLiteDatabase db) {
// Add the column used for title localization, and force a rescan of any
// ringtones, alarms and notifications that may be using it.
db.execSQL("ALTER TABLE files ADD COLUMN title_resource_uri TEXT");
db.execSQL("UPDATE files SET date_modified=0"
+ " WHERE (is_alarm IS 1) OR (is_ringtone IS 1) OR (is_notification IS 1)");
}
private static void updateAddOwnerPackageName(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN owner_package_name TEXT DEFAULT NULL");
// Derive new column value based on well-known paths
try (Cursor c = db.query("files", new String[] { FileColumns._ID, FileColumns.DATA },
FileColumns.DATA + " REGEXP '" + FileUtils.PATTERN_OWNED_PATH.pattern() + "'",
null, null, null, null, null)) {
Log.d(TAG, "Updating " + c.getCount() + " entries with well-known owners");
final Matcher m = FileUtils.PATTERN_OWNED_PATH.matcher("");
final ContentValues values = new ContentValues();
while (c.moveToNext()) {
final long id = c.getLong(0);
final String data = c.getString(1);
m.reset(data);
if (m.matches()) {
final String packageName = m.group(1);
values.clear();
values.put(FileColumns.OWNER_PACKAGE_NAME, packageName);
db.update("files", values, "_id=" + id, null);
}
}
}
}
private static void updateAddColorSpaces(SQLiteDatabase db) {
// Add the color aspects related column used for HDR detection etc.
db.execSQL("ALTER TABLE files ADD COLUMN color_standard INTEGER;");
db.execSQL("ALTER TABLE files ADD COLUMN color_transfer INTEGER;");
db.execSQL("ALTER TABLE files ADD COLUMN color_range INTEGER;");
}
private static void updateAddHashAndPending(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN _hash BLOB DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN is_pending INTEGER DEFAULT 0;");
}
private static void updateAddDownloadInfo(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN is_download INTEGER DEFAULT 0;");
db.execSQL("ALTER TABLE files ADD COLUMN download_uri TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN referer_uri TEXT DEFAULT NULL;");
}
private static void updateAddAudiobook(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN is_audiobook INTEGER DEFAULT 0;");
}
private static void updateAddRecording(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN is_recording INTEGER DEFAULT 0;");
// We add the column is_recording, rescan all music files
db.execSQL("UPDATE files SET date_modified=0 WHERE is_music=1;");
}
private static void updateAddRedactedUriId(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN redacted_uri_id TEXT DEFAULT NULL;");
}
private static void updateClearLocation(SQLiteDatabase db) {
db.execSQL("UPDATE files SET latitude=NULL, longitude=NULL;");
}
private static void updateSetIsDownload(SQLiteDatabase db) {
db.execSQL("UPDATE files SET is_download=1 WHERE _data REGEXP '"
+ FileUtils.PATTERN_DOWNLOADS_FILE + "'");
}
private static void updateAddExpiresAndTrashed(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN date_expires INTEGER DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN is_trashed INTEGER DEFAULT 0;");
}
private static void updateAddGroupId(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN group_id INTEGER DEFAULT NULL;");
}
private static void updateAddDirectories(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN primary_directory TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN secondary_directory TEXT DEFAULT NULL;");
}
private static void updateAddXmpMm(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN document_id TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN instance_id TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN original_document_id TEXT DEFAULT NULL;");
}
private static void updateAddPath(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN relative_path TEXT DEFAULT NULL;");
}
private static void updateAddVolumeName(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN volume_name TEXT DEFAULT NULL;");
}
private static void updateDirsMimeType(SQLiteDatabase db) {
db.execSQL("UPDATE files SET mime_type=NULL WHERE format="
+ MtpConstants.FORMAT_ASSOCIATION);
}
private static void updateRelativePath(SQLiteDatabase db) {
db.execSQL("UPDATE files"
+ " SET " + MediaColumns.RELATIVE_PATH + "=" + MediaColumns.RELATIVE_PATH + "||'/'"
+ " WHERE " + MediaColumns.RELATIVE_PATH + " IS NOT NULL"
+ " AND " + MediaColumns.RELATIVE_PATH + " NOT LIKE '%/';");
}
private static void updateAddTranscodeSatus(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN _transcode_status INTEGER DEFAULT 0;");
}
private static void updateAddSpecialFormat(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN _special_format INTEGER DEFAULT NULL;");
}
private static void updateSpecialFormatToNotDetected(SQLiteDatabase db) {
db.execSQL("UPDATE files SET _special_format=NULL WHERE _special_format=0");
}
private static void updateAddVideoCodecType(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN _video_codec_type TEXT DEFAULT NULL;");
}
private static void updateClearDirectories(SQLiteDatabase db) {
db.execSQL("UPDATE files SET primary_directory=NULL, secondary_directory=NULL;");
}
private static void updateRestructureAudio(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN artist_key TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN album_key TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN genre TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN genre_key TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN genre_id INTEGER;");
db.execSQL("DROP TABLE IF EXISTS artists;");
db.execSQL("DROP TABLE IF EXISTS albums;");
db.execSQL("DROP TABLE IF EXISTS audio_genres;");
db.execSQL("DROP TABLE IF EXISTS audio_genres_map;");
db.execSQL("CREATE INDEX genre_id_idx ON files(genre_id)");
db.execSQL("DROP INDEX IF EXISTS album_idx");
db.execSQL("DROP INDEX IF EXISTS albumkey_index");
db.execSQL("DROP INDEX IF EXISTS artist_idx");
db.execSQL("DROP INDEX IF EXISTS artistkey_index");
// Since we're radically changing how the schema is defined, the
// simplest path forward is to rescan all audio files
db.execSQL("UPDATE files SET date_modified=0 WHERE media_type=2;");
}
private static void updateAddMetadata(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN author TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN bitrate INTEGER DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN capture_framerate REAL DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN cd_track_number TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN compilation INTEGER DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN disc_number TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN is_favorite INTEGER DEFAULT 0;");
db.execSQL("ALTER TABLE files ADD COLUMN num_tracks INTEGER DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN writer TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN exposure_time TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN f_number TEXT DEFAULT NULL;");
db.execSQL("ALTER TABLE files ADD COLUMN iso INTEGER DEFAULT NULL;");
}
private static void updateAddSceneCaptureType(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN scene_capture_type INTEGER DEFAULT NULL;");
}
private static void updateMigrateLogs(SQLiteDatabase db) {
// Migrate any existing logs to new system
try (Cursor c = db.query("log", new String[] { "time", "message" },
null, null, null, null, null)) {
while (c.moveToNext()) {
final String time = c.getString(0);
final String message = c.getString(1);
Logging.logPersistent("Historical log " + time + " " + message);
}
}
db.execSQL("DELETE FROM log;");
}
private static void updateAddLocalMetadata(SQLiteDatabase db) {
db.execSQL("CREATE TABLE local_metadata (generation INTEGER DEFAULT 0)");
db.execSQL("INSERT INTO local_metadata VALUES (0)");
}
private static void updateAddGeneration(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN generation_added INTEGER DEFAULT 0;");
db.execSQL("ALTER TABLE files ADD COLUMN generation_modified INTEGER DEFAULT 0;");
}
private static void updateAddXmp(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN xmp BLOB DEFAULT NULL;");
}
private static void updateAudioAlbumId(SQLiteDatabase db) {
// We change the logic for generating album id, rescan all audio files
db.execSQL("UPDATE files SET date_modified=0 WHERE media_type=2;");
}
private static void updateAddModifier(SQLiteDatabase db) {
db.execSQL("ALTER TABLE files ADD COLUMN _modifier INTEGER DEFAULT 0;");
// For existing files, set default value as _MODIFIER_MEDIA_SCAN
db.execSQL("UPDATE files SET _modifier=3;");
}
private static void updateAddDeletedMediaTable(SQLiteDatabase db) {
db.execSQL("CREATE TABLE deleted_media (_id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "old_id INTEGER UNIQUE, generation_modified INTEGER NOT NULL)");
}
private void updateUserId(SQLiteDatabase db) {
db.execSQL(String.format("ALTER TABLE files ADD COLUMN _user_id INTEGER DEFAULT %d;",
UserHandle.myUserId()));
}
private static void recomputeDataValues(SQLiteDatabase db) {
try (Cursor c = db.query("files", new String[] { FileColumns._ID, FileColumns.DATA },
null, null, null, null, null, null)) {
Log.d(TAG, "Recomputing " + c.getCount() + " data values");
final ContentValues values = new ContentValues();
while (c.moveToNext()) {
values.clear();
final long id = c.getLong(0);
final String data = c.getString(1);
values.put(FileColumns.DATA, data);
FileUtils.computeValuesFromData(values, /*isForFuse*/ false);
values.remove(FileColumns.DATA);
if (!values.isEmpty()) {
db.update("files", values, "_id=" + id, null);
}
}
}
}
private static void recomputeMediaTypeValues(SQLiteDatabase db) {
// Only update the files with MEDIA_TYPE_NONE.
final String selection = FileColumns.MEDIA_TYPE + "=?";
final String[] selectionArgs = new String[]{String.valueOf(FileColumns.MEDIA_TYPE_NONE)};
ArrayMap<Long, Integer> newMediaTypes = new ArrayMap<>();
try (Cursor c = db.query("files", new String[] { FileColumns._ID, FileColumns.MIME_TYPE },
selection, selectionArgs, null, null, null, null)) {
Log.d(TAG, "Recomputing " + c.getCount() + " MediaType values");
// Accumulate all the new MEDIA_TYPE updates.
while (c.moveToNext()) {
final long id = c.getLong(0);
final String mimeType = c.getString(1);
// Only update Document and Subtitle media type
if (MimeUtils.isSubtitleMimeType(mimeType)) {
newMediaTypes.put(id, FileColumns.MEDIA_TYPE_SUBTITLE);
} else if (MimeUtils.isDocumentMimeType(mimeType)) {
newMediaTypes.put(id, FileColumns.MEDIA_TYPE_DOCUMENT);
}
}
}
// Now, update all the new MEDIA_TYPE values.
final ContentValues values = new ContentValues();
for (long id: newMediaTypes.keySet()) {
values.clear();
values.put(FileColumns.MEDIA_TYPE, newMediaTypes.get(id));
db.update("files", values, "_id=" + id, null);
}
}
static final int VERSION_J = 509;
static final int VERSION_K = 700;
static final int VERSION_L = 700;
static final int VERSION_M = 800;
static final int VERSION_N = 800;
static final int VERSION_O = 800;
static final int VERSION_P = 900;
static final int VERSION_Q = 1023;
static final int VERSION_R = 1115;
static final int VERSION_S = 1209;
// Leave some gaps in database version tagging to allow S schema changes
// to go independent of T schema changes.
static final int VERSION_T = 1307;
public static final int VERSION_LATEST = VERSION_T;
/**
* This method takes care of updating all the tables in the database to the
* current version, creating them if necessary.
* This method can only update databases at schema 700 or higher, which was
* used by the KitKat release. Older database will be cleared and recreated.
* @param db Database
*/
private void updateDatabase(SQLiteDatabase db, int fromVersion, int toVersion) {
final long startTime = SystemClock.elapsedRealtime();
if (fromVersion < 700) {
// Anything older than KK is recreated from scratch
createLatestSchema(db);
} else {
boolean recomputeDataValues = false;
if (fromVersion < 800) {
updateCollationKeys(db);
}
if (fromVersion < 900) {
updateAddTitleResource(db);
}
if (fromVersion < 1000) {
updateAddOwnerPackageName(db);
}
if (fromVersion < 1003) {
updateAddColorSpaces(db);
}
if (fromVersion < 1004) {
updateAddHashAndPending(db);
}
if (fromVersion < 1005) {
updateAddDownloadInfo(db);
}
if (fromVersion < 1006) {
updateAddAudiobook(db);
}
if (fromVersion < 1007) {
updateClearLocation(db);
}
if (fromVersion < 1008) {
updateSetIsDownload(db);
}
if (fromVersion < 1009) {
// This database version added "secondary_bucket_id", but that
// column name was refactored in version 1013 below, so this
// update step is no longer needed.
}
if (fromVersion < 1010) {
updateAddExpiresAndTrashed(db);
}
if (fromVersion < 1012) {
recomputeDataValues = true;
}
if (fromVersion < 1013) {
updateAddGroupId(db);
updateAddDirectories(db);
recomputeDataValues = true;
}
if (fromVersion < 1014) {
updateAddXmpMm(db);
}
if (fromVersion < 1015) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1016) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1017) {
updateSetIsDownload(db);
recomputeDataValues = true;
}
if (fromVersion < 1018) {
updateAddPath(db);
recomputeDataValues = true;
}
if (fromVersion < 1019) {
// Only trigger during "external", so that it runs only once.
if (isExternal()) {
deleteLegacyThumbnailData();
}
}
if (fromVersion < 1020) {
updateAddVolumeName(db);
recomputeDataValues = true;
}
if (fromVersion < 1021) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1022) {
updateDirsMimeType(db);
}
if (fromVersion < 1023) {
updateRelativePath(db);
}
if (fromVersion < 1100) {
// Empty version bump to ensure triggers are recreated
}
if (fromVersion < 1101) {
updateClearDirectories(db);
}
if (fromVersion < 1102) {
updateRestructureAudio(db);
}
if (fromVersion < 1103) {
updateAddMetadata(db);
}
if (fromVersion < 1104) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1105) {
recomputeDataValues = true;
}
if (fromVersion < 1106) {
updateMigrateLogs(db);
}
if (fromVersion < 1107) {
updateAddSceneCaptureType(db);
}
if (fromVersion < 1108) {
updateAddLocalMetadata(db);
}
if (fromVersion < 1109) {
updateAddGeneration(db);
}
if (fromVersion < 1110) {
// Empty version bump to ensure triggers are recreated
}
if (fromVersion < 1111) {
recomputeMediaTypeValues(db);
}
if (fromVersion < 1112) {
updateAddXmp(db);
}
if (fromVersion < 1113) {
// Empty version bump to ensure triggers are recreated
}
if (fromVersion < 1114) {
// Empty version bump to ensure triggers are recreated
}
if (fromVersion < 1115) {
updateAudioAlbumId(db);
}
if (fromVersion < 1200) {
updateAddTranscodeSatus(db);
}
if (fromVersion < 1201) {
updateAddVideoCodecType(db);
}
if (fromVersion < 1202) {
updateAddModifier(db);
}
if (fromVersion < 1203) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1204) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1205) {
updateAddRecording(db);
}
if (fromVersion < 1206) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1207) {
updateAddRedactedUriId(db);
}
if (fromVersion < 1208) {
updateUserId(db);
}
if (fromVersion < 1209) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1301) {
updateAddDeletedMediaTable(db);
}
if (fromVersion < 1302) {
updateAddSpecialFormat(db);
}
if (fromVersion < 1303) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1304) {
updateSpecialFormatToNotDetected(db);
}
if (fromVersion < 1305) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1306) {
// Empty version bump to ensure views are recreated
}
if (fromVersion < 1307) {
// This is to ensure Animated Webp files are tagged
updateSpecialFormatToNotDetected(db);
}
// If this is the legacy database, it's not worth recomputing data
// values locally, since they'll be recomputed after the migration
if (mLegacyProvider) {
recomputeDataValues = false;
}
if (recomputeDataValues) {
recomputeDataValues(db);
}
}
// Always recreate latest views and triggers during upgrade; they're
// cheap and it's an easy way to ensure they're defined consistently
createLatestViews(db);
createLatestTriggers(db);
getOrCreateUuid(db);
final long elapsedMillis = (SystemClock.elapsedRealtime() - startTime);
if (mSchemaListener != null) {
mSchemaListener.onSchemaChange(mVolumeName, fromVersion, toVersion,
getItemCount(db), elapsedMillis, getOrCreateUuid(db));
}
}
private void downgradeDatabase(SQLiteDatabase db, int fromVersion, int toVersion) {
final long startTime = SystemClock.elapsedRealtime();
// The best we can do is wipe and start over
createLatestSchema(db);
final long elapsedMillis = (SystemClock.elapsedRealtime() - startTime);
if (mSchemaListener != null) {
mSchemaListener.onSchemaChange(mVolumeName, fromVersion, toVersion,
getItemCount(db), elapsedMillis, getOrCreateUuid(db));
}
}
private static final String XATTR_UUID = "user.uuid";
/**
* Return a UUID for the given database. If the database is deleted or
* otherwise corrupted, then a new UUID will automatically be generated.
*/
public static @NonNull String getOrCreateUuid(@NonNull SQLiteDatabase db) {
try {
return new String(Os.getxattr(db.getPath(), XATTR_UUID));
} catch (ErrnoException e) {
if (e.errno == OsConstants.ENODATA) {
// Doesn't exist yet, so generate and persist a UUID
return resetAndGetUuid(db);
} else {
throw new RuntimeException(e);
}
}
}
private static @NonNull String resetAndGetUuid(SQLiteDatabase db) {
final String uuid = UUID.randomUUID().toString();
try {
Os.setxattr(db.getPath(), XATTR_UUID, uuid.getBytes(), 0);
} catch (ErrnoException e) {
throw new RuntimeException(e);
}
return uuid;
}
private static final long PASSTHROUGH_WAIT_TIMEOUT = 10 * DateUtils.SECOND_IN_MILLIS;
/**
* When writing to playlist files during migration, the underlying
* pass-through view of storage may not be mounted yet, so we're willing
* to retry several times before giving up.
* The retry logic is mainly added to avoid test flakiness.
*/
private static void writeToPlaylistFileWithRetry(@NonNull File playlistFile,
@NonNull Playlist playlist) throws IOException {
final long start = SystemClock.elapsedRealtime();
while (true) {
if (SystemClock.elapsedRealtime() - start > PASSTHROUGH_WAIT_TIMEOUT) {
throw new IOException("Passthrough failed to mount");
}
try {
playlistFile.getParentFile().mkdirs();
playlistFile.createNewFile();
playlist.write(playlistFile);
return;
} catch (IOException e) {
Log.i(TAG, "Failed to migrate playlist file, retrying " + e);
}
Log.i(TAG, "Waiting for passthrough to be mounted...");
SystemClock.sleep(100);
}
}
/**
* When renaming files during migration, the underlying pass-through view of
* storage may not be mounted yet, so we're willing to retry several times
* before giving up.
*/
private static void renameWithRetry(@NonNull String oldPath, @NonNull String newPath)
throws IOException {
final long start = SystemClock.elapsedRealtime();
while (true) {
if (SystemClock.elapsedRealtime() - start > PASSTHROUGH_WAIT_TIMEOUT) {
throw new IOException("Passthrough failed to mount");
}
try {
Os.rename(oldPath, newPath);
return;
} catch (ErrnoException e) {
Log.i(TAG, "Failed to rename: " + e);
}
Log.i(TAG, "Waiting for passthrough to be mounted...");
SystemClock.sleep(100);
}
}
private boolean shouldBeIgnoredByOsVersion(@NonNull Field field) {
if (mExportedSinceAnnotation == null) {
return false;
}
if (!field.isAnnotationPresent(mExportedSinceAnnotation)) {
return false;
}
try {
final Annotation annotation = field.getAnnotation(mExportedSinceAnnotation);
final int exportedSinceOSVersion = (int) annotation.annotationType().getMethod(
"osVersion").invoke(annotation);
final boolean shouldIgnore = exportedSinceOSVersion > Build.VERSION.SDK_INT;
if (shouldIgnore) {
Log.d(TAG, "Ignoring column " + field.get(null) + " with version "
+ exportedSinceOSVersion + " in OS version " + Build.VERSION.SDK_INT);
}
return shouldIgnore;
} catch (Exception e) {
Log.e(TAG, "Can't parse the OS version in ExportedSince annotation", e);
return false;
}
}
/**
* Return the current generation that will be populated into
* {@link MediaColumns#GENERATION_ADDED} or
* {@link MediaColumns#GENERATION_MODIFIED}.
*/
public static long getGeneration(@NonNull SQLiteDatabase db) {
return android.database.DatabaseUtils.longForQuery(db,
CURRENT_GENERATION_CLAUSE + ";", null);
}
/**
* Return total number of items tracked inside this database. This includes
* only real media items, and does not include directories.
*/
public static long getItemCount(@NonNull SQLiteDatabase db) {
return android.database.DatabaseUtils.longForQuery(db,
"SELECT COUNT(_id) FROM files WHERE " + FileColumns.MIME_TYPE + " IS NOT NULL",
null);
}
public boolean isInternal() {
return mName.equals(INTERNAL_DATABASE_NAME);
}
public boolean isExternal() {
// Matches test dbs as external
switch (mName) {
case EXTERNAL_DATABASE_NAME:
return true;
case TEST_RECOMPUTE_DB:
return true;
case TEST_UPGRADE_DB:
return true;
case TEST_DOWNGRADE_DB:
return true;
case TEST_CLEAN_DB:
return true;
default:
return false;
}
}
}