Merge "Support creating Playlist and Subtitle file" into rvc-dev
diff --git a/Android.bp b/Android.bp
index b7092bc..34acc32 100644
--- a/Android.bp
+++ b/Android.bp
@@ -12,6 +12,9 @@
     libs: [
         "unsupportedappusage",
         "app-compat-annotations",
+        "framework-mediaprovider",
+        "framework_mediaprovider_annotation",
+        "framework-statsd-stubs-module_libs_api",
     ],
 
     jni_libs: [
@@ -26,10 +29,6 @@
         ":mediaprovider-sources",
     ],
 
-    // Rewrite our hidden API usage of MediaStore to avoid "Inlined method
-    // resolution crossed dex file boundary" errors
-    jarjar_rules: "jarjar-rules.txt",
-
     optimize: {
         proguard_flags_files: ["proguard.flags"],
     },
@@ -38,7 +37,7 @@
         "java_api_finder",
     ],
 
-    sdk_version: "system_current",
+    sdk_version: "module_current",
 
     certificate: "media",
     privileged: true,
@@ -46,15 +45,12 @@
     aaptflags: ["--custom-package com.android.providers.media"],
 }
 
-// This is defined to give MediaProviderTests all the source it needs to
-// run its tests against
+// Used by MediaProvider and MediaProviderTests
 filegroup {
     name: "mediaprovider-sources",
     srcs: [
         "src/**/*.aidl",
         "src/**/*.java",
-        ":framework-mediaprovider-sources",
-        ":framework-mediaprovider-annotation-sources",
         ":mediaprovider-database-sources",
         ":statslog-mediaprovider-java-gen",
     ],
diff --git a/apex/framework/Android.bp b/apex/framework/Android.bp
index f5747d5..7cb77f9 100644
--- a/apex/framework/Android.bp
+++ b/apex/framework/Android.bp
@@ -34,7 +34,7 @@
     plugins: ["java_api_finder"],
 
     hostdex: true, // for hiddenapi check
-    visibility: ["//packages/providers/MediaProvider/apex:__subpackages__"],
+    visibility: ["//packages/providers/MediaProvider:__subpackages__"],
     apex_available: [
         "com.android.mediaprovider",
         "test_com.android.mediaprovider",
diff --git a/jarjar-rules.txt b/jarjar-rules.txt
deleted file mode 100644
index a16c1ab..0000000
--- a/jarjar-rules.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-rule android.provider.Column* com.android.providers.media.framework.Column@1
-rule android.provider.MediaStore* com.android.providers.media.framework.MediaStore@1
diff --git a/legacy/src/com/android/providers/media/LegacyMediaProvider.java b/legacy/src/com/android/providers/media/LegacyMediaProvider.java
index 086f88c..a505ee1 100644
--- a/legacy/src/com/android/providers/media/LegacyMediaProvider.java
+++ b/legacy/src/com/android/providers/media/LegacyMediaProvider.java
@@ -94,8 +94,10 @@
     public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
             String sortOrder) {
         final DatabaseHelper helper = getDatabaseForUri(uri);
-        return helper.getReadableDatabase().query("files", projection, selection, selectionArgs,
-                null, null, sortOrder);
+        return helper.runWithoutTransaction((db) -> {
+            return db.query("files", projection, selection, selectionArgs,
+                    null, null, sortOrder);
+        });
     }
 
     @Override
@@ -114,7 +116,9 @@
         }
 
         final DatabaseHelper helper = getDatabaseForUri(uri);
-        final long id = helper.getWritableDatabase().insert("files", null, values);
+        final long id = helper.runWithTransaction((db) -> {
+            return db.insert("files", null, values);
+        });
         return ContentUris.withAppendedId(uri, id);
     }
 
diff --git a/src/com/android/providers/media/DatabaseHelper.java b/src/com/android/providers/media/DatabaseHelper.java
index 4f9528e..3913065 100644
--- a/src/com/android/providers/media/DatabaseHelper.java
+++ b/src/com/android/providers/media/DatabaseHelper.java
@@ -35,7 +35,6 @@
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.Environment;
-import android.os.Looper;
 import android.os.SystemClock;
 import android.os.Trace;
 import android.provider.MediaStore;
@@ -75,7 +74,8 @@
 import java.util.Objects;
 import java.util.Set;
 import java.util.UUID;
-import java.util.function.LongSupplier;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Function;
 import java.util.function.UnaryOperator;
 import java.util.regex.Matcher;
 
@@ -110,8 +110,19 @@
     final Set<String> mFilterVolumeNames = new ArraySet<>();
     long mScanStartTime;
     long mScanStopTime;
-    /** Flag indicating if a schema change is in progress */
-    boolean mSchemaChanging;
+
+    /**
+     * 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();
 
     public interface OnSchemaChangeListener {
         public void onSchemaChange(@NonNull String volumeName, int versionFrom, int versionTo,
@@ -195,26 +206,33 @@
             mFilterVolumeNames.addAll(filterVolumeNames);
         }
 
-        // We might be tempted to open a transaction and recreate views here,
-        // but that would result in an obscure deadlock; instead we simply close
-        // the entire database, letting the views be recreated the next time
-        // it's opened.
-        close();
+        // Recreate all views to apply this filter
+        final SQLiteDatabase db = super.getWritableDatabase();
+        mSchemaLock.writeLock().lock();
+        try {
+            db.beginTransaction();
+            createLatestViews(db, mInternal);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+            mSchemaLock.writeLock().unlock();
+        }
     }
 
     @Override
     public SQLiteDatabase getReadableDatabase() {
-        if (Looper.myLooper() == Looper.getMainLooper()) {
-            Log.wtf(TAG, "Database operations must not happen on main thread", new Throwable());
-        }
-        return super.getReadableDatabase();
+        throw new UnsupportedOperationException("All database operations must be routed through"
+                + " runWithTransaction() or runWithoutTransaction() to avoid deadlocks");
     }
 
     @Override
     public SQLiteDatabase getWritableDatabase() {
-        if (Looper.myLooper() == Looper.getMainLooper()) {
-            Log.wtf(TAG, "Database operations must not happen on main thread", new Throwable());
-        }
+        throw new UnsupportedOperationException("All database operations must be routed through"
+                + " runWithTransaction() or runWithoutTransaction() to avoid deadlocks");
+    }
+
+    @VisibleForTesting
+    SQLiteDatabase getWritableDatabaseForTest() {
         return super.getWritableDatabase();
     }
 
@@ -222,7 +240,8 @@
     public void onConfigure(SQLiteDatabase db) {
         Log.v(TAG, "onConfigure() for " + mName);
         db.setCustomScalarFunction("_INSERT", (arg) -> {
-            if (arg != null && mFilesListener != null && !mSchemaChanging) {
+            if (arg != null && mFilesListener != null
+                    && !mSchemaLock.isWriteLockedByCurrentThread()) {
                 final String[] split = arg.split(":");
                 final String volumeName = split[0];
                 final long id = Long.parseLong(split[1]);
@@ -240,7 +259,8 @@
             return null;
         });
         db.setCustomScalarFunction("_UPDATE", (arg) -> {
-            if (arg != null && mFilesListener != null && !mSchemaChanging) {
+            if (arg != null && mFilesListener != null
+                    && !mSchemaLock.isWriteLockedByCurrentThread()) {
                 final String[] split = arg.split(":");
                 final String volumeName = split[0];
                 final long oldId = Long.parseLong(split[1]);
@@ -265,7 +285,8 @@
             return null;
         });
         db.setCustomScalarFunction("_DELETE", (arg) -> {
-            if (arg != null && mFilesListener != null && !mSchemaChanging) {
+            if (arg != null && mFilesListener != null
+                    && !mSchemaLock.isWriteLockedByCurrentThread()) {
                 final String[] split = arg.split(":");
                 final String volumeName = split[0];
                 final long id = Long.parseLong(split[1]);
@@ -286,7 +307,7 @@
             return null;
         });
         db.setCustomScalarFunction("_GET_ID", (arg) -> {
-            if (mIdGenerator != null && !mSchemaChanging) {
+            if (mIdGenerator != null && !mSchemaLock.isWriteLockedByCurrentThread()) {
                 Trace.beginSection("_GET_ID");
                 try {
                     return mIdGenerator.apply(arg);
@@ -299,43 +320,35 @@
     }
 
     @Override
-    public void onOpen(SQLiteDatabase db) {
-        // Always recreate latest views and triggers during open; they're
-        // cheap and it's an easy way to ensure they're defined consistently
-        createLatestViews(db, mInternal);
-        createLatestTriggers(db, mInternal);
-    }
-
-    @Override
     public void onCreate(final SQLiteDatabase db) {
         Log.v(TAG, "onCreate() for " + mName);
-        mSchemaChanging = true;
+        mSchemaLock.writeLock().lock();
         try {
             updateDatabase(db, 0, mVersion);
         } finally {
-            mSchemaChanging = false;
+            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);
-        mSchemaChanging = true;
+        mSchemaLock.writeLock().lock();
         try {
             updateDatabase(db, oldV, newV);
         } finally {
-            mSchemaChanging = false;
+            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);
-        mSchemaChanging = true;
+        mSchemaLock.writeLock().lock();
         try {
             downgradeDatabase(db, oldV, newV);
         } finally {
-            mSchemaChanging = false;
+            mSchemaLock.writeLock().unlock();
         }
     }
 
@@ -411,7 +424,8 @@
         }
         mTransactionState.set(new TransactionState());
 
-        final SQLiteDatabase db = getWritableDatabase();
+        final SQLiteDatabase db = super.getWritableDatabase();
+        mSchemaLock.readLock().lock();
         db.beginTransaction();
         db.execSQL("UPDATE local_metadata SET generation=generation+1;");
     }
@@ -423,7 +437,7 @@
         }
         state.successful = true;
 
-        final SQLiteDatabase db = getWritableDatabase();
+        final SQLiteDatabase db = super.getWritableDatabase();
         db.setTransactionSuccessful();
     }
 
@@ -434,8 +448,9 @@
         }
         mTransactionState.remove();
 
-        final SQLiteDatabase db = getWritableDatabase();
+        final SQLiteDatabase db = super.getWritableDatabase();
         db.endTransaction();
+        mSchemaLock.readLock().unlock();
 
         if (state.successful) {
             // We carefully "phase" our two sets of work here to ensure that we
@@ -458,19 +473,23 @@
     }
 
     /**
-     * Execute the given runnable inside a transaction. If the calling thread is
-     * not already in an active transaction, this method will wrap the given
+     * 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 long runWithTransaction(@NonNull LongSupplier s) {
+    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 s.getAsLong();
+            return op.apply(db);
         } else {
             // Not inside a transaction, so we need to make one
             beginTransaction();
             try {
-                final long res = s.getAsLong();
+                final T res = op.apply(db);
                 setTransactionSuccessful();
                 return res;
             } finally {
@@ -479,6 +498,29 @@
         }
     }
 
+    /**
+     * 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);
     }
@@ -734,10 +776,32 @@
                 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);
                     for (String column : sMigrateColumns) {
                         DatabaseUtils.copyFromCursorToContentValues(column, c, values);
                     }
 
+                    // When migrating pending or trashed files, we might need to
+                    // rename them on disk to match new schema
+                    FileUtils.computeDataFromValues(values,
+                            new File(FileUtils.extractVolumePath(data)));
+                    final String recomputedData = values.getAsString(MediaColumns.DATA);
+                    if (!Objects.equals(data, recomputedData)) {
+                        try {
+                            Os.rename(data, recomputedData);
+                        } catch (ErrnoException e) {
+                            // We only have one shot to migrate data, so log and
+                            // keep marching forward
+                            Log.w(TAG, "Failed to rename " + values + "; continuing");
+                            FileUtils.computeValuesFromData(values);
+                        }
+                    }
+
                     if (db.insert("files", null, values) == -1) {
                         // We only have one shot to migrate data, so log and
                         // keep marching forward
@@ -752,7 +816,7 @@
                 // We have to guard ourselves against any weird behavior of the
                 // legacy provider by trying to catch everything
                 db.execSQL("ROLLBACK TO before_migrate");
-                Log.w(TAG, "Failed migration from legacy provider: " + e);
+                Log.wtf(TAG, "Failed migration from legacy provider", e);
                 mMigrationListener.onFinished(client, mVolumeName);
             }
         }
@@ -1119,7 +1183,7 @@
                 final long id = c.getLong(0);
                 final String data = c.getString(1);
                 values.put(FileColumns.DATA, data);
-                FileUtils.computeDataValues(values);
+                FileUtils.computeValuesFromData(values);
                 values.remove(FileColumns.DATA);
                 if (!values.isEmpty()) {
                     db.update("files", values, "_id=" + id, null);
@@ -1309,6 +1373,11 @@
             }
         }
 
+        // 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, internal);
+        createLatestTriggers(db, internal);
+
         getOrCreateUuid(db);
 
         final long elapsedMillis = (SystemClock.elapsedRealtime() - startTime);
@@ -1361,8 +1430,8 @@
      * {@link MediaColumns#GENERATION_ADDED} or
      * {@link MediaColumns#GENERATION_MODIFIED}.
      */
-    public long getGeneration() {
-        return android.database.DatabaseUtils.longForQuery(getReadableDatabase(),
+    public static long getGeneration(@NonNull SQLiteDatabase db) {
+        return android.database.DatabaseUtils.longForQuery(db,
                 CURRENT_GENERATION_CLAUSE + ";", null);
     }
 
@@ -1370,15 +1439,7 @@
      * Return total number of items tracked inside this database. This includes
      * only real media items, and does not include directories.
      */
-    public long getItemCount() {
-        return getItemCount(getReadableDatabase());
-    }
-
-    /**
-     * Return total number of items tracked inside this database. This includes
-     * only real media items, and does not include directories.
-     */
-    private long getItemCount(SQLiteDatabase db) {
+    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);
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 60c4b39..c843ce9 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -52,7 +52,6 @@
 import static com.android.providers.media.scan.MediaScanner.REASON_DEMAND;
 import static com.android.providers.media.scan.MediaScanner.REASON_IDLE;
 import static com.android.providers.media.util.DatabaseUtils.bindList;
-import static com.android.providers.media.util.FileUtils.computeDataValues;
 import static com.android.providers.media.util.FileUtils.extractDisplayName;
 import static com.android.providers.media.util.FileUtils.extractFileName;
 import static com.android.providers.media.util.FileUtils.extractPathOwnerPackageName;
@@ -62,7 +61,6 @@
 import static com.android.providers.media.util.FileUtils.extractVolumeName;
 import static com.android.providers.media.util.FileUtils.getAbsoluteSanitizedPath;
 import static com.android.providers.media.util.FileUtils.isDownload;
-import static com.android.providers.media.util.FileUtils.sanitizeDisplayName;
 import static com.android.providers.media.util.FileUtils.sanitizePath;
 import static com.android.providers.media.util.Logging.LOGV;
 import static com.android.providers.media.util.Logging.TAG;
@@ -127,7 +125,6 @@
 import android.os.Trace;
 import android.os.UserHandle;
 import android.os.storage.StorageManager;
-import android.os.storage.StorageManager.StorageVolumeCallback;
 import android.os.storage.StorageVolume;
 import android.preference.PreferenceManager;
 import android.provider.BaseColumns;
@@ -688,7 +685,7 @@
      * devices. We only do this once per volume so we don't annoy the user if
      * deleted manually.
      */
-    private void ensureDefaultFolders(String volumeName, DatabaseHelper helper) {
+    private void ensureDefaultFolders(@NonNull String volumeName, @NonNull SQLiteDatabase db) {
         try {
             final File path = getVolumePath(volumeName);
             final StorageVolume vol = mStorageManager.getStorageVolume(path);
@@ -711,7 +708,7 @@
                     final File folder = new File(vol.getDirectory(), folderName);
                     if (!folder.exists()) {
                         folder.mkdirs();
-                        insertDirectory(helper, folder.getAbsolutePath());
+                        insertDirectory(db, folder.getAbsolutePath());
                     }
                 }
 
@@ -730,9 +727,8 @@
      * {@link DatabaseHelper#getOrCreateUuid} doesn't match the UUID found on
      * disk, then all thumbnails will be considered stable and will be deleted.
      */
-    private void ensureThumbnailsValid(String volumeName, DatabaseHelper helper) {
-        final String uuidFromDatabase = DatabaseHelper
-                .getOrCreateUuid(helper.getReadableDatabase());
+    private void ensureThumbnailsValid(@NonNull String volumeName, @NonNull SQLiteDatabase db) {
+        final String uuidFromDatabase = DatabaseHelper.getOrCreateUuid(db);
         try {
             for (File dir : getThumbnailDirectories(volumeName)) {
                 if (!dir.exists()) {
@@ -893,9 +889,6 @@
         // Trim any stale log files before we emit new events below
         Logging.trimPersistent();
 
-        final DatabaseHelper helper = mExternalDatabase;
-        final SQLiteDatabase db = helper.getReadableDatabase();
-
         // Scan all volumes to resolve any staleness
         for (String volumeName : getExternalVolumeNames()) {
             // Possibly bail before digging into each volume
@@ -908,81 +901,100 @@
             }
 
             // Ensure that our thumbnails are valid
-            ensureThumbnailsValid(volumeName, helper);
+            mExternalDatabase.runWithTransaction((db) -> {
+                ensureThumbnailsValid(volumeName, db);
+                return null;
+            });
         }
 
         // Delete any stale thumbnails
-        final int staleThumbnails = pruneThumbnails(signal);
+        final int staleThumbnails = mExternalDatabase.runWithTransaction((db) -> {
+            return pruneThumbnails(db, signal);
+        });
         Log.d(TAG, "Pruned " + staleThumbnails + " unknown thumbnails");
 
         // Finished orphaning any content whose package no longer exists
-        final ArraySet<String> unknownPackages = new ArraySet<>();
-        try (Cursor c = db.query(true, "files", new String[] { "owner_package_name" },
-                null, null, null, null, null, null, signal)) {
-            while (c.moveToNext()) {
-                final String packageName = c.getString(0);
-                if (TextUtils.isEmpty(packageName)) continue;
+        final int stalePackages = mExternalDatabase.runWithTransaction((db) -> {
+            final ArraySet<String> unknownPackages = new ArraySet<>();
+            try (Cursor c = db.query(true, "files", new String[] { "owner_package_name" },
+                    null, null, null, null, null, null, signal)) {
+                while (c.moveToNext()) {
+                    final String packageName = c.getString(0);
+                    if (TextUtils.isEmpty(packageName)) continue;
 
-                if (!isPackageKnown(packageName)) {
-                    unknownPackages.add(packageName);
+                    if (!isPackageKnown(packageName)) {
+                        unknownPackages.add(packageName);
+                    }
                 }
             }
-        }
-
-        for (String packageName : unknownPackages) {
-            onPackageOrphaned(packageName);
-        }
-        final int stalePackages = unknownPackages.size();
+            for (String packageName : unknownPackages) {
+                onPackageOrphaned(db, packageName);
+            }
+            return unknownPackages.size();
+        });
         Log.d(TAG, "Pruned " + stalePackages + " unknown packages");
 
         // Delete any expired content; we're paranoid about wildly changing
         // clocks, so only delete items within the last week
         final long from = ((System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS) / 1000);
         final long to = (System.currentTimeMillis() / 1000);
-        final int expiredMedia;
-        try (Cursor c = db.query(true, "files", new String[] { "volume_name", "_id" },
-                FileColumns.DATE_EXPIRES + " BETWEEN " + from + " AND " + to, null,
-                null, null, null, null, signal)) {
-            while (c.moveToNext()) {
-                final String volumeName = c.getString(0);
-                final long id = c.getLong(1);
-                delete(Files.getContentUri(volumeName, id), null, null);
+        final int expiredMedia = mExternalDatabase.runWithTransaction((db) -> {
+            try (Cursor c = db.query(true, "files", new String[] { "volume_name", "_id" },
+                    FileColumns.DATE_EXPIRES + " BETWEEN " + from + " AND " + to, null,
+                    null, null, null, null, signal)) {
+                while (c.moveToNext()) {
+                    final String volumeName = c.getString(0);
+                    final long id = c.getLong(1);
+                    delete(Files.getContentUri(volumeName, id), null, null);
+                }
+                return c.getCount();
             }
-            expiredMedia = c.getCount();
-            Log.d(TAG, "Deleted " + expiredMedia + " expired items on " + helper.mName);
-        }
+        });
+        Log.d(TAG, "Deleted " + expiredMedia + " expired items");
 
         // Forget any stale volumes
-        final Set<String> recentVolumeNames = MediaStore.getRecentExternalVolumeNames(getContext());
-        final Set<String> knownVolumeNames = new ArraySet<>();
-        try (Cursor c = db.query(true, "files", new String[] { MediaColumns.VOLUME_NAME },
-                null, null, null, null, null, null, signal)) {
-            while (c.moveToNext()) {
-                knownVolumeNames.add(c.getString(0));
+        mExternalDatabase.runWithTransaction((db) -> {
+            final Set<String> recentVolumeNames = MediaStore
+                    .getRecentExternalVolumeNames(getContext());
+            final Set<String> knownVolumeNames = new ArraySet<>();
+            try (Cursor c = db.query(true, "files", new String[] { MediaColumns.VOLUME_NAME },
+                    null, null, null, null, null, null, signal)) {
+                while (c.moveToNext()) {
+                    knownVolumeNames.add(c.getString(0));
+                }
             }
-        }
-        final Set<String> staleVolumeNames = new ArraySet<>();
-        staleVolumeNames.addAll(knownVolumeNames);
-        staleVolumeNames.removeAll(recentVolumeNames);
-        for (String staleVolumeName : staleVolumeNames) {
-            final int num = db.delete("files", FileColumns.VOLUME_NAME + "=?",
-                    new String[] { staleVolumeName });
-            Log.d(TAG, "Forgot " + num + " stale items from " + staleVolumeName);
-        }
+            final Set<String> staleVolumeNames = new ArraySet<>();
+            staleVolumeNames.addAll(knownVolumeNames);
+            staleVolumeNames.removeAll(recentVolumeNames);
+            for (String staleVolumeName : staleVolumeNames) {
+                final int num = db.delete("files", FileColumns.VOLUME_NAME + "=?",
+                        new String[] { staleVolumeName });
+                Log.d(TAG, "Forgot " + num + " stale items from " + staleVolumeName);
+            }
+            return null;
+        });
 
         synchronized (mDirectoryCache) {
             mDirectoryCache.clear();
         }
 
+        final long itemCount = mExternalDatabase.runWithTransaction((db) -> {
+            return DatabaseHelper.getItemCount(db);
+        });
+
         final long durationMillis = (SystemClock.elapsedRealtime() - startTime);
-        Metrics.logIdleMaintenance(MediaStore.VOLUME_EXTERNAL, helper.getItemCount(),
+        Metrics.logIdleMaintenance(MediaStore.VOLUME_EXTERNAL, itemCount,
                 durationMillis, staleThumbnails, expiredMedia);
     }
 
     public void onPackageOrphaned(String packageName) {
-        final DatabaseHelper helper = mExternalDatabase;
-        final SQLiteDatabase db = helper.getWritableDatabase();
+        mExternalDatabase.runWithTransaction((db) -> {
+            onPackageOrphaned(db, packageName);
+            return null;
+        });
+    }
 
+    public void onPackageOrphaned(@NonNull SQLiteDatabase db, @NonNull String packageName) {
         final ContentValues values = new ContentValues();
         values.putNull(FileColumns.OWNER_PACKAGE_NAME);
 
@@ -990,7 +1002,7 @@
                 "owner_package_name=?", new String[] { packageName });
         if (count > 0) {
             Log.d(TAG, "Orphaned " + count + " items belonging to "
-                    + packageName + " on " + helper.mName);
+                    + packageName + " on " + db.getPath());
         }
     }
 
@@ -1377,7 +1389,7 @@
             computeAudioLocalizedValues(values);
             computeAudioKeyValues(values);
         }
-        computeDataValues(values);
+        FileUtils.computeValuesFromData(values);
         return values;
     }
 
@@ -2105,14 +2117,7 @@
 
         // Force values when raw path provided
         if (!TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) {
-            final String data = values.getAsString(MediaColumns.DATA);
-
-            if (TextUtils.isEmpty(values.getAsString(MediaColumns.DISPLAY_NAME))) {
-                values.put(MediaColumns.DISPLAY_NAME, extractDisplayName(data));
-            }
-            if (TextUtils.isEmpty(values.getAsString(MediaColumns.MIME_TYPE))) {
-                values.put(MediaColumns.MIME_TYPE, MimeUtils.resolveMimeType(new File(data)));
-            }
+            FileUtils.computeValuesFromData(values);
         }
         // Extract the MIME type from the display name if we couldn't resolve it from the raw path
         if (!TextUtils.isEmpty(values.getAsString(MediaColumns.DISPLAY_NAME))) {
@@ -2170,42 +2175,42 @@
             }
         }
 
+        // Use default directories when missing
+        if (TextUtils.isEmpty(values.getAsString(MediaColumns.RELATIVE_PATH))) {
+            if (defaultSecondary != null) {
+                values.put(MediaColumns.RELATIVE_PATH,
+                        defaultPrimary + '/' + defaultSecondary + '/');
+            } else {
+                values.put(MediaColumns.RELATIVE_PATH,
+                        defaultPrimary + '/');
+            }
+        }
+
         // Generate path when undefined
         if (TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) {
-            if (TextUtils.isEmpty(values.getAsString(MediaColumns.RELATIVE_PATH))) {
-                if (defaultPrimary != null) {
-                    if (defaultSecondary != null) {
-                        values.put(MediaColumns.RELATIVE_PATH,
-                                defaultPrimary + '/' + defaultSecondary + '/');
-                    } else {
-                        values.put(MediaColumns.RELATIVE_PATH,
-                                defaultPrimary + '/');
-                    }
-                }
-            }
-
-            final String[] relativePath = sanitizePath(
-                    values.getAsString(MediaColumns.RELATIVE_PATH));
-            final String displayName = sanitizeDisplayName(
-                    values.getAsString(MediaColumns.DISPLAY_NAME));
-
-            // Create result file
-            File res;
+            File volumePath;
             try {
-                res = getVolumePath(resolvedVolumeName);
+                volumePath = getVolumePath(resolvedVolumeName);
             } catch (FileNotFoundException e) {
                 throw new IllegalArgumentException(e);
             }
-            res = FileUtils.buildPath(res, relativePath);
+
+            FileUtils.sanitizeValues(values);
+            FileUtils.computeDataFromValues(values, volumePath);
+
+            // Create result file
+            File res = new File(values.getAsString(MediaColumns.DATA));
             try {
                 if (makeUnique) {
-                    res = FileUtils.buildUniqueFile(res, mimeType, displayName);
+                    res = FileUtils.buildUniqueFile(res.getParentFile(),
+                            mimeType, res.getName());
                 } else {
-                    res = FileUtils.buildNonUniqueFile(res, mimeType, displayName);
+                    res = FileUtils.buildNonUniqueFile(res.getParentFile(),
+                            mimeType, res.getName());
                 }
             } catch (FileNotFoundException e) {
                 throw new IllegalStateException(
-                        "Failed to build unique file: " + res + " " + displayName + " " + mimeType);
+                        "Failed to build unique file: " + res + " " + values);
             }
 
             // Require that content lives under well-defined directories to help
@@ -2215,7 +2220,8 @@
             boolean validPath = res.getAbsolutePath().equals(currentPath);
 
             // Next, consider allowing based on allowed primary directory
-            final String primary = relativePath[0];
+            final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
+            final String primary = (relativePath.length > 0) ? relativePath[0] : null;
             if (!validPath) {
                 validPath = allowedPrimary.contains(primary);
             }
@@ -2275,8 +2281,13 @@
             case AUDIO_ALBUMART:
             case VIDEO_THUMBNAILS:
             case IMAGES_THUMBNAILS:
-                values.remove(MediaColumns.DISPLAY_NAME);
-                values.remove(MediaColumns.MIME_TYPE);
+                final Set<String> valid = getProjectionMap(MediaStore.Images.Thumbnails.class)
+                        .keySet();
+                for (String key : new ArraySet<>(values.keySet())) {
+                    if (!valid.contains(key)) {
+                        values.remove(key);
+                    }
+                }
                 break;
         }
 
@@ -2331,12 +2342,12 @@
         }
     }
 
-    private long insertDirectory(DatabaseHelper helper, String path) {
+    private long insertDirectory(@NonNull SQLiteDatabase db, @NonNull String path) {
         if (LOGV) Log.v(TAG, "inserting directory " + path);
         ContentValues values = new ContentValues();
         values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
         values.put(FileColumns.DATA, path);
-        values.put(FileColumns.PARENT, getParent(helper, path));
+        values.put(FileColumns.PARENT, getParent(db, path));
         values.put(FileColumns.OWNER_PACKAGE_NAME, extractPathOwnerPackageName(path));
         values.put(FileColumns.VOLUME_NAME, extractVolumeName(path));
         values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path));
@@ -2346,12 +2357,10 @@
         if (file.exists()) {
             values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000);
         }
-        final SQLiteDatabase db = helper.getWritableDatabase();
-        long rowId = db.insert("files", FileColumns.DATE_MODIFIED, values);
-        return rowId;
+        return db.insert("files", FileColumns.DATE_MODIFIED, values);
     }
 
-    private long getParent(DatabaseHelper helper, String path) {
+    private long getParent(@NonNull SQLiteDatabase db, @NonNull String path) {
         final String parentPath = new File(path).getParent();
         if (Objects.equals("/", parentPath)) {
             return -1;
@@ -2364,13 +2373,12 @@
             }
 
             final long id;
-            final SQLiteDatabase db = helper.getReadableDatabase();
             try (Cursor c = db.query("files", new String[] { FileColumns._ID },
                     FileColumns.DATA + "=?", new String[] { parentPath }, null, null, null)) {
                 if (c.moveToFirst()) {
                     id = c.getLong(0);
                 } else {
-                    id = insertDirectory(helper, parentPath);
+                    id = insertDirectory(db, parentPath);
                 }
             }
 
@@ -2490,13 +2498,13 @@
     }
 
     public void onLocaleChanged() {
-        localizeTitles();
+        mInternalDatabase.runWithTransaction((db) -> {
+            localizeTitles(db);
+            return null;
+        });
     }
 
-    private void localizeTitles() {
-        final DatabaseHelper helper = mInternalDatabase;
-        final SQLiteDatabase db = helper.getWritableDatabase();
-
+    private void localizeTitles(@NonNull SQLiteDatabase db) {
         try (Cursor c = db.query("files", new String[]{"_id", "title_resource_uri"},
             "title_resource_uri IS NOT NULL", null, null, null, null)) {
             while (c.moveToNext()) {
@@ -2544,7 +2552,7 @@
 
         // compute bucket_id and bucket_display_name for all files
         String path = values.getAsString(MediaStore.MediaColumns.DATA);
-        computeDataValues(values);
+        FileUtils.computeValuesFromData(values);
         values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000);
 
         String title = values.getAsString(MediaStore.MediaColumns.TITLE);
@@ -2618,7 +2626,9 @@
             Long parent = values.getAsLong(FileColumns.PARENT);
             if (parent == null) {
                 if (path != null) {
-                    long parentId = getParent(helper, path);
+                    final long parentId = helper.runWithTransaction((db) -> {
+                        return getParent(db, path);
+                    });
                     values.put(FileColumns.PARENT, parentId);
                 }
             }
@@ -2641,7 +2651,7 @@
     private long insertAllowingUpsert(@NonNull SQLiteQueryBuilder qb,
             @NonNull DatabaseHelper helper, @NonNull ContentValues values, String path)
             throws SQLiteConstraintException {
-        return helper.runWithTransaction(() -> {
+        return helper.runWithTransaction((db) -> {
             try {
                 return qb.insert(helper, values);
             } catch (SQLiteConstraintException e) {
@@ -2939,7 +2949,7 @@
                 values.put(MediaStore.Audio.Playlists.DATE_ADDED, System.currentTimeMillis() / 1000);
                 // Playlist names are stored as display names, but leave
                 // values untouched if the caller is ModernMediaScanner
-                if (Binder.getCallingUid() != android.os.Process.myUid()) {
+                if (!isCallingPackageSystem()) {
                     if (values.containsKey(Playlists.NAME)) {
                         values.put(MediaColumns.DISPLAY_NAME, values.getAsString(Playlists.NAME));
                     }
@@ -3818,16 +3828,19 @@
                             // Update any playlists that reference this item
                             if ((mediaType == FileColumns.MEDIA_TYPE_AUDIO)
                                     && helper.isExternal()) {
-                                final SQLiteDatabase db = helper.getReadableDatabase();
-                                try (Cursor cc = db.query("audio_playlists_map",
-                                        new String[] { "playlist_id" }, "audio_id=" + id,
-                                        null, "playlist_id", null, null)) {
-                                    while (cc.moveToNext()) {
-                                        final Uri playlistUri = ContentUris.withAppendedId(
-                                                Playlists.getContentUri(volumeName), cc.getLong(0));
-                                        resolvePlaylistMembers(playlistUri);
+                                helper.runWithTransaction((db) -> {
+                                    try (Cursor cc = db.query("audio_playlists_map",
+                                            new String[] { "playlist_id" }, "audio_id=" + id,
+                                            null, "playlist_id", null, null)) {
+                                        while (cc.moveToNext()) {
+                                            final Uri playlistUri = ContentUris.withAppendedId(
+                                                    Playlists.getContentUri(volumeName),
+                                                    cc.getLong(0));
+                                            resolvePlaylistMembers(playlistUri);
+                                        }
                                     }
-                                }
+                                    return null;
+                                });
                             }
                         }
                     } finally {
@@ -3897,7 +3910,7 @@
         synchronized (mDirectoryCache) {
             mDirectoryCache.clear();
 
-            return (int) helper.runWithTransaction(() -> {
+            return (int) helper.runWithTransaction((db) -> {
                 int n = 0;
                 int total = 0;
                 do {
@@ -3982,15 +3995,16 @@
             case MediaStore.GET_VERSION_CALL: {
                 final String volumeName = extras.getString(Intent.EXTRA_TEXT);
 
-                final SQLiteDatabase db;
+                final DatabaseHelper helper;
                 try {
-                    db = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName))
-                            .getReadableDatabase();
+                    helper = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName));
                 } catch (VolumeNotFoundException e) {
                     throw e.rethrowAsIllegalArgumentException();
                 }
 
-                final String version = db.getVersion() + ":" + DatabaseHelper.getOrCreateUuid(db);
+                final String version = helper.runWithoutTransaction((db) -> {
+                    return db.getVersion() + ":" + DatabaseHelper.getOrCreateUuid(db);
+                });
 
                 final Bundle res = new Bundle();
                 res.putString(Intent.EXTRA_TEXT, version);
@@ -3999,14 +4013,17 @@
             case MediaStore.GET_GENERATION_CALL: {
                 final String volumeName = extras.getString(Intent.EXTRA_TEXT);
 
-                final long generation;
+                final DatabaseHelper helper;
                 try {
-                    generation = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName))
-                            .getGeneration();
+                    helper = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName));
                 } catch (VolumeNotFoundException e) {
                     throw e.rethrowAsIllegalArgumentException();
                 }
 
+                final long generation = helper.runWithoutTransaction((db) -> {
+                    return DatabaseHelper.getGeneration(db);
+                });
+
                 final Bundle res = new Bundle();
                 res.putLong(Intent.EXTRA_INDEX, generation);
                 return res;
@@ -4163,9 +4180,11 @@
                         mInternalDatabase,
                         mExternalDatabase
                 }) {
-                    final SQLiteDatabase db = helper.getReadableDatabase();
-                    db.execPerConnectionSQL("SELECT icu_load_collation(?, ?);",
-                            new String[] { locale, collationName });
+                    helper.runWithoutTransaction((db) -> {
+                        db.execPerConnectionSQL("SELECT icu_load_collation(?, ?);",
+                                new String[] { locale, collationName });
+                        return null;
+                    });
                 }
                 mCustomCollators.add(collationName);
             }
@@ -4173,12 +4192,9 @@
         return collationName;
     }
 
-    private int pruneThumbnails(@NonNull CancellationSignal signal) {
+    private int pruneThumbnails(@NonNull SQLiteDatabase db, @NonNull CancellationSignal signal) {
         int prunedCount = 0;
 
-        final DatabaseHelper helper = mExternalDatabase;
-        final SQLiteDatabase db = helper.getReadableDatabase();
-
         // Determine all known media items
         final LongArray knownIds = new LongArray();
         try (Cursor c = db.query(true, "files", new String[] { BaseColumns._ID },
@@ -4365,27 +4381,28 @@
         }
 
         final DatabaseHelper helper;
-        final SQLiteDatabase db;
         try {
             helper = getDatabaseForUri(uri);
-            db = helper.getWritableDatabase();
         } catch (VolumeNotFoundException e) {
             Log.w(TAG, e);
             return;
         }
 
-        final String idString = Long.toString(id);
-        try (Cursor c = db.rawQuery("select _data from thumbnails where image_id=?"
-                + " union all select _data from videothumbnails where video_id=?",
-                new String[] { idString, idString })) {
-            while (c.moveToNext()) {
-                String path = c.getString(0);
-                deleteIfAllowed(uri, Bundle.EMPTY, path);
+        helper.runWithTransaction((db) -> {
+            final String idString = Long.toString(id);
+            try (Cursor c = db.rawQuery("select _data from thumbnails where image_id=?"
+                    + " union all select _data from videothumbnails where video_id=?",
+                    new String[] { idString, idString })) {
+                while (c.moveToNext()) {
+                    String path = c.getString(0);
+                    deleteIfAllowed(uri, Bundle.EMPTY, path);
+                }
             }
-        }
 
-        db.execSQL("delete from thumbnails where image_id=?", new String[] { idString });
-        db.execSQL("delete from videothumbnails where video_id=?", new String[] { idString });
+            db.execSQL("delete from thumbnails where image_id=?", new String[] { idString });
+            db.execSQL("delete from videothumbnails where video_id=?", new String[] { idString });
+            return null;
+        });
     }
 
     /**
@@ -4597,7 +4614,7 @@
             case AUDIO_PLAYLISTS_ID:
                 // Playlist names are stored as display names, but leave
                 // values untouched if the caller is ModernMediaScanner
-                if (Binder.getCallingUid() != android.os.Process.myUid()) {
+                if (!isCallingPackageSystem()) {
                     if (initialValues.containsKey(Playlists.NAME)) {
                         initialValues.put(MediaColumns.DISPLAY_NAME,
                                 initialValues.getAsString(Playlists.NAME));
@@ -4624,6 +4641,7 @@
                 case VIDEO_MEDIA_ID:
                 case IMAGES_MEDIA_ID:
                 case DOWNLOADS_ID:
+                case FILES_ID:
                     break;
                 default:
                     throw new IllegalArgumentException("Movement of " + uri
@@ -4737,7 +4755,7 @@
             case IMAGES_MEDIA_ID:
             case FILES_ID:
             case DOWNLOADS_ID: {
-                computeDataValues(values);
+                FileUtils.computeValuesFromData(values);
                 break;
             }
         }
@@ -4787,21 +4805,24 @@
     private void resolvePlaylistMembers(@NonNull Uri playlistUri) {
         Trace.beginSection("resolvePlaylistMembers");
         try {
-            resolvePlaylistMembersInternal(playlistUri);
+            final DatabaseHelper helper;
+            try {
+                helper = getDatabaseForUri(playlistUri);
+            } catch (VolumeNotFoundException e) {
+                throw e.rethrowAsIllegalArgumentException();
+            }
+
+            helper.runWithTransaction((db) -> {
+                resolvePlaylistMembersInternal(playlistUri, db);
+                return null;
+            });
         } finally {
             Trace.endSection();
         }
     }
 
-    private void resolvePlaylistMembersInternal(@NonNull Uri playlistUri) {
-        final SQLiteDatabase db;
-        try {
-            db = getDatabaseForUri(playlistUri).getWritableDatabase();
-        } catch (VolumeNotFoundException e) {
-            throw e.rethrowAsIllegalArgumentException();
-        }
-
-        db.beginTransaction();
+    private void resolvePlaylistMembersInternal(@NonNull Uri playlistUri,
+            @NonNull SQLiteDatabase db) {
         try {
             // Refresh playlist members based on what we parse from disk
             final long playlistId = ContentUris.parseId(playlistUri);
@@ -4827,11 +4848,8 @@
                     }
                 }
             }
-            db.setTransactionSuccessful();
         } catch (IOException e) {
             Log.w(TAG, "Failed to refresh playlist", e);
-        } finally {
-            db.endTransaction();
         }
     }
 
@@ -6509,8 +6527,11 @@
             ForegroundThread.getExecutor().execute(() -> {
                 final DatabaseHelper helper = MediaStore.VOLUME_INTERNAL.equals(volume)
                         ? mInternalDatabase : mExternalDatabase;
-                ensureDefaultFolders(volume, helper);
-                ensureThumbnailsValid(volume, helper);
+                helper.runWithTransaction((db) -> {
+                    ensureDefaultFolders(volume, db);
+                    ensureThumbnailsValid(volume, db);
+                    return null;
+                });
             });
         }
         return uri;
@@ -6753,6 +6774,9 @@
         sPlacementColumns.add(MediaStore.MediaColumns.RELATIVE_PATH);
         sPlacementColumns.add(MediaStore.MediaColumns.DISPLAY_NAME);
         sPlacementColumns.add(MediaStore.MediaColumns.MIME_TYPE);
+        sPlacementColumns.add(MediaStore.MediaColumns.IS_PENDING);
+        sPlacementColumns.add(MediaStore.MediaColumns.IS_TRASHED);
+        sPlacementColumns.add(MediaStore.MediaColumns.DATE_EXPIRES);
     }
 
     /**
diff --git a/src/com/android/providers/media/MediaUpgradeReceiver.java b/src/com/android/providers/media/MediaUpgradeReceiver.java
index 932b20b..b9fba09 100644
--- a/src/com/android/providers/media/MediaUpgradeReceiver.java
+++ b/src/com/android/providers/media/MediaUpgradeReceiver.java
@@ -20,7 +20,6 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.SharedPreferences;
-import android.database.sqlite.SQLiteDatabase;
 import android.provider.Column;
 import android.util.Log;
 
@@ -65,19 +64,19 @@
                 if (MediaProvider.isMediaDatabaseName(file)) {
                     long startTime = System.currentTimeMillis();
                     Log.i(TAG, "---> Start upgrade of media database " + file);
-                    SQLiteDatabase db = null;
                     try {
                         DatabaseHelper helper = new DatabaseHelper(
                                 context, file, MediaProvider.isInternalMediaDatabaseName(file),
                                 false, false, Column.class, Metrics::logSchemaChange, null, null,
                                 null);
-                        db = helper.getWritableDatabase();
+                        helper.runWithTransaction((db) -> {
+                            // Perform just enough to force database upgrade
+                            return db.getVersion();
+                        });
+                        helper.close();
                     } catch (Throwable t) {
                         Log.wtf(TAG, "Error during upgrade of media db " + file, t);
                     } finally {
-                        if (db != null) {
-                            db.close();
-                        }
                     }
                     Log.i(TAG, "<--- Finished upgrade of media database " + file
                             + " in " + (System.currentTimeMillis()-startTime) + "ms");
diff --git a/src/com/android/providers/media/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java
index 4540d56..dae8cfb 100644
--- a/src/com/android/providers/media/scan/ModernMediaScanner.java
+++ b/src/com/android/providers/media/scan/ModernMediaScanner.java
@@ -684,8 +684,7 @@
      */
     private static @Nullable ContentProviderOperation.Builder scanItem(long existingId, File file,
             BasicFileAttributes attrs, String mimeType, String volumeName) {
-        final String name = file.getName();
-        if (name.startsWith(".")) {
+        if (isFileHidden(file)) {
             if (LOGD) Log.d(TAG, "Ignoring hidden file: " + file);
             return null;
         }
@@ -695,7 +694,7 @@
         }
 
         int mediaType = MimeUtils.resolveMediaType(mimeType);
-        if (mediaType == FileColumns.MEDIA_TYPE_IMAGE && isFileAlbumArt(name)) {
+        if (mediaType == FileColumns.MEDIA_TYPE_IMAGE && isFileAlbumArt(file)) {
             mediaType = FileColumns.MEDIA_TYPE_NONE;
         }
         switch (mediaType) {
@@ -1260,7 +1259,7 @@
     /**
      * Test if any parents of given directory should be considered hidden.
      */
-    static boolean isDirectoryHiddenRecursive(File dir) {
+    static boolean isDirectoryHiddenRecursive(@NonNull File dir) {
         Trace.beginSection("isDirectoryHiddenRecursive");
         try {
             while (dir != null) {
@@ -1278,7 +1277,8 @@
     /**
      * Test if this given directory should be considered hidden.
      */
-    static boolean isDirectoryHidden(File dir) {
+    @VisibleForTesting
+    static boolean isDirectoryHidden(@NonNull File dir) {
         final File nomedia = new File(dir, ".nomedia");
 
         // Handle well-known paths that should always be visible or invisible,
@@ -1307,16 +1307,36 @@
         return false;
     }
 
+    /**
+     * Test if this given file should be considered hidden.
+     */
     @VisibleForTesting
-    static boolean isFileAlbumArt(String name) {
-        return PATTERN_ALBUM_ART.matcher(name).matches();
+    static boolean isFileHidden(@NonNull File file) {
+        final String name = file.getName();
+
+        // Handle well-known file names that are pending or trashed; they
+        // normally appear hidden, but we give them special treatment
+        if (FileUtils.PATTERN_EXPIRES_FILE.matcher(name).matches()) {
+            return false;
+        }
+
+        // Otherwise fall back to file name
+        if (name.startsWith(".")) {
+            return true;
+        }
+        return false;
+    }
+
+    @VisibleForTesting
+    static boolean isFileAlbumArt(@NonNull File file) {
+        return PATTERN_ALBUM_ART.matcher(file.getName()).matches();
     }
 
     /**
      * Test if this given {@link Uri} is a
      * {@link android.provider.MediaStore.Audio.Playlists} item.
      */
-    static boolean isPlaylist(Uri uri) {
+    static boolean isPlaylist(@NonNull Uri uri) {
         final List<String> path = uri.getPathSegments();
         return (path.size() == 4) && path.get(1).equals("audio") && path.get(2).equals("playlists");
     }
@@ -1333,7 +1353,7 @@
         return true;
     }
 
-    static void logTroubleScanning(File file, Exception e) {
+    static void logTroubleScanning(@NonNull File file, @NonNull Exception e) {
         if (LOGW) Log.w(TAG, "Trouble scanning " + file + ": " + e);
     }
 }
diff --git a/src/com/android/providers/media/util/DatabaseUtils.java b/src/com/android/providers/media/util/DatabaseUtils.java
index a42233f..ef33b04 100644
--- a/src/com/android/providers/media/util/DatabaseUtils.java
+++ b/src/com/android/providers/media/util/DatabaseUtils.java
@@ -531,4 +531,16 @@
         }
         return sb.toString();
     }
+
+    public static boolean getAsBoolean(@NonNull ContentValues values,
+            @NonNull String key, boolean def) {
+        final Integer value = values.getAsInteger(key);
+        return (value != null) ? (value != 0) : def;
+    }
+
+    public static long getAsLong(@NonNull ContentValues values,
+            @NonNull String key, long def) {
+        final Long value = values.getAsLong(key);
+        return (value != null) ? value : def;
+    }
 }
diff --git a/src/com/android/providers/media/util/FileUtils.java b/src/com/android/providers/media/util/FileUtils.java
index c051bef..fcee8bd 100644
--- a/src/com/android/providers/media/util/FileUtils.java
+++ b/src/com/android/providers/media/util/FileUtils.java
@@ -16,6 +16,8 @@
 
 package com.android.providers.media.util;
 
+import static com.android.providers.media.util.DatabaseUtils.getAsBoolean;
+import static com.android.providers.media.util.DatabaseUtils.getAsLong;
 import static com.android.providers.media.util.Logging.TAG;
 
 import android.content.ClipDescription;
@@ -25,9 +27,9 @@
 import android.os.Environment;
 import android.os.storage.StorageManager;
 import android.provider.MediaStore;
-import android.provider.MediaStore.Images.ImageColumns;
 import android.provider.MediaStore.MediaColumns;
 import android.text.TextUtils;
+import android.text.format.DateUtils;
 import android.util.Log;
 import android.webkit.MimeTypeMap;
 
@@ -46,7 +48,6 @@
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
-import java.nio.file.SimpleFileVisitor;
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -260,7 +261,7 @@
      * Recursively delete all contents inside the given directory. Gracefully
      * attempts to delete as much as possible in the face of any failures.
      *
-     * @deprecated if you're calling this from inside {@link MediaProvider}, you
+     * @deprecated if you're calling this from inside {@code MediaProvider}, you
      *             likely want to call {@link #forEach} with a separate
      *             invocation to invalidate FUSE entries.
      */
@@ -639,6 +640,30 @@
             "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/.+");
     public static final Pattern PATTERN_DOWNLOADS_DIRECTORY = Pattern.compile(
             "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/?");
+    public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile(
+            "(?i)^\\.(pending|trashed)-(\\d+)-(.+)$");
+
+    /**
+     * File prefix indicating that the file {@link MediaColumns#IS_PENDING}.
+     */
+    public static final String PREFIX_PENDING = "pending";
+
+    /**
+     * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}.
+     */
+    public static final String PREFIX_TRASHED = "trashed";
+
+    /**
+     * Default duration that {@link MediaColumns#IS_PENDING} items should be
+     * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
+     */
+    public static final long DEFAULT_DURATION_PENDING = DateUtils.WEEK_IN_MILLIS;
+
+    /**
+     * Default duration that {@link MediaColumns#IS_TRASHED} items should be
+     * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
+     */
+    public static final long DEFAULT_DURATION_TRASHED = DateUtils.WEEK_IN_MILLIS;
 
     public static boolean isDownload(@NonNull String path) {
         return PATTERN_DOWNLOADS_FILE.matcher(path).matches();
@@ -678,6 +703,16 @@
         return fsUuid != null ? fsUuid.toLowerCase(Locale.US) : null;
     }
 
+    public static @Nullable String extractVolumePath(@Nullable String data) {
+        if (data == null) return null;
+        final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
+        if (matcher.find()) {
+            return data.substring(0, matcher.end());
+        } else {
+            return null;
+        }
+    }
+
     public static @Nullable String extractVolumeName(@Nullable String data) {
         if (data == null) return null;
         final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
@@ -750,12 +785,22 @@
         return null;
     }
 
-    public static void computeDataValues(@NonNull ContentValues values) {
+    /**
+     * Compute several scattered {@link MediaColumns} values from
+     * {@link MediaColumns#DATA}. This method performs no enforcement of
+     * argument validity.
+     */
+    public static void computeValuesFromData(@NonNull ContentValues values) {
         // Worst case we have to assume no bucket details
-        values.remove(ImageColumns.BUCKET_ID);
-        values.remove(ImageColumns.BUCKET_DISPLAY_NAME);
-        values.remove(ImageColumns.VOLUME_NAME);
-        values.remove(ImageColumns.RELATIVE_PATH);
+        values.remove(MediaColumns.VOLUME_NAME);
+        values.remove(MediaColumns.RELATIVE_PATH);
+        values.remove(MediaColumns.IS_DOWNLOAD);
+        values.remove(MediaColumns.IS_PENDING);
+        values.remove(MediaColumns.IS_TRASHED);
+        values.remove(MediaColumns.DATE_EXPIRES);
+        values.remove(MediaColumns.DISPLAY_NAME);
+        values.remove(MediaColumns.BUCKET_ID);
+        values.remove(MediaColumns.BUCKET_DISPLAY_NAME);
 
         final String data = values.getAsString(MediaColumns.DATA);
         if (TextUtils.isEmpty(data)) return;
@@ -763,21 +808,78 @@
         final File file = new File(data);
         final File fileLower = new File(data.toLowerCase(Locale.ROOT));
 
-        values.put(ImageColumns.VOLUME_NAME, extractVolumeName(data));
-        values.put(ImageColumns.RELATIVE_PATH, extractRelativePath(data));
-        values.put(ImageColumns.DISPLAY_NAME, extractDisplayName(data));
+        values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data));
+        values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data));
+        values.put(MediaColumns.IS_DOWNLOAD, isDownload(data));
+
+        final String displayName = extractDisplayName(data);
+        final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(displayName);
+        if (matcher.matches()) {
+            values.put(MediaColumns.IS_PENDING,
+                    matcher.group(1).equals(FileUtils.PREFIX_PENDING) ? 1 : 0);
+            values.put(MediaColumns.IS_TRASHED,
+                    matcher.group(1).equals(FileUtils.PREFIX_TRASHED) ? 1 : 0);
+            values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2)));
+            values.put(MediaColumns.DISPLAY_NAME, matcher.group(3));
+        } else {
+            values.put(MediaColumns.IS_PENDING, 0);
+            values.put(MediaColumns.IS_TRASHED, 0);
+            values.putNull(MediaColumns.DATE_EXPIRES);
+            values.put(MediaColumns.DISPLAY_NAME, displayName);
+        }
 
         // Buckets are the parent directory
         final String parent = fileLower.getParent();
         if (parent != null) {
-            values.put(ImageColumns.BUCKET_ID, parent.hashCode());
+            values.put(MediaColumns.BUCKET_ID, parent.hashCode());
             // The relative path for files in the top directory is "/"
-            if (!"/".equals(values.getAsString(ImageColumns.RELATIVE_PATH))) {
-                values.put(ImageColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName());
+            if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) {
+                values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName());
             }
         }
     }
 
+    /**
+     * Compute {@link MediaColumns#DATA} from several scattered
+     * {@link MediaColumns} values.  This method performs no enforcement of
+     * argument validity.
+     */
+    public static void computeDataFromValues(@NonNull ContentValues values,
+            @NonNull File volumePath) {
+        values.remove(MediaColumns.DATA);
+
+        final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
+        final String resolvedDisplayName;
+        if (getAsBoolean(values, MediaColumns.IS_PENDING, false)) {
+            final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
+                    (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
+            resolvedDisplayName = String.format(".%s-%d-%s",
+                    FileUtils.PREFIX_PENDING, dateExpires, displayName);
+        } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) {
+            final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
+                    (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
+            resolvedDisplayName = String.format(".%s-%d-%s",
+                    FileUtils.PREFIX_TRASHED, dateExpires, displayName);
+        } else {
+            resolvedDisplayName = displayName;
+        }
+
+        final File filePath = buildPath(volumePath,
+                values.getAsString(MediaColumns.RELATIVE_PATH), resolvedDisplayName);
+        values.put(MediaColumns.DATA, filePath.getAbsolutePath());
+    }
+
+    public static void sanitizeValues(@NonNull ContentValues values) {
+        final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
+        for (int i = 0; i < relativePath.length; i++) {
+            relativePath[i] = sanitizeDisplayName(relativePath[i]);
+        }
+        values.put(MediaColumns.RELATIVE_PATH,
+                String.join("/", relativePath) + "/");
+        values.put(MediaColumns.DISPLAY_NAME,
+                sanitizeDisplayName(values.getAsString(MediaColumns.DISPLAY_NAME)));
+    }
+
     /** {@hide} **/
     @Nullable
     public static String getAbsoluteSanitizedPath(String path) {
diff --git a/src/com/android/providers/media/util/SQLiteQueryBuilder.java b/src/com/android/providers/media/util/SQLiteQueryBuilder.java
index da279aa..e3e0e71 100644
--- a/src/com/android/providers/media/util/SQLiteQueryBuilder.java
+++ b/src/com/android/providers/media/util/SQLiteQueryBuilder.java
@@ -425,8 +425,10 @@
     public Cursor query(DatabaseHelper helper, String[] projectionIn,
             String selection, String[] selectionArgs, String groupBy,
             String having, String sortOrder, String limit, CancellationSignal cancellationSignal) {
-        return query(helper.getReadableDatabase(), projectionIn, selection, selectionArgs, groupBy,
-                having, sortOrder, limit, cancellationSignal);
+        return helper.runWithoutTransaction((db) -> {
+            return query(db, projectionIn, selection, selectionArgs, groupBy,
+                    having, sortOrder, limit, cancellationSignal);
+        });
     }
 
     /**
@@ -524,8 +526,8 @@
     public long insert(@NonNull DatabaseHelper helper, @NonNull ContentValues values) {
         // We force wrap in a transaction to ensure that all mutations increment
         // the generation counter
-        return (int) helper.runWithTransaction(() -> {
-            return insert(helper.getWritableDatabase(), values);
+        return helper.runWithTransaction((db) -> {
+            return insert(db, values);
         });
     }
 
@@ -568,8 +570,8 @@
             @Nullable String selection, @Nullable String[] selectionArgs) {
         // We force wrap in a transaction to ensure that all mutations increment
         // the generation counter
-        return (int) helper.runWithTransaction(() -> {
-            return update(helper.getWritableDatabase(), values, selection, selectionArgs);
+        return helper.runWithTransaction((db) -> {
+            return update(db, values, selection, selectionArgs);
         });
     }
 
@@ -654,8 +656,8 @@
             @Nullable String[] selectionArgs) {
         // We force wrap in a transaction to ensure that all mutations increment
         // the generation counter
-        return (int) helper.runWithTransaction(() -> {
-            return delete(helper.getWritableDatabase(), selection, selectionArgs);
+        return helper.runWithTransaction((db) -> {
+            return delete(db, selection, selectionArgs);
         });
     }
 
diff --git a/tests/Android.bp b/tests/Android.bp
index ba71320..e74d9bb 100644
--- a/tests/Android.bp
+++ b/tests/Android.bp
@@ -17,6 +17,7 @@
         "res",
     ],
     srcs: [
+        ":framework-mediaprovider-sources",
         ":mediaprovider-sources",
         "src/**/*.java",
     ],
@@ -38,6 +39,7 @@
         "androidx.test.rules",
         "guava",
         "mockito-target",
+        "truth-prebuilt",
     ],
 
     certificate: "media",
diff --git a/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java b/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java
index a7d7e98..79efb4c 100644
--- a/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java
+++ b/tests/client/src/com/android/providers/media/client/ClientPlaylistTest.java
@@ -20,8 +20,6 @@
 import static android.provider.MediaStore.VOLUME_INTERNAL;
 
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
 import android.content.ContentResolver;
@@ -113,38 +111,6 @@
         mContentResolver.delete(ContentUris.withAppendedId(mExternalAudio, mBlue), null);
     }
 
-    /**
-     * Verify that creating playlists using only {@link Playlists#NAME} defined
-     * will flow into the {@link MediaColumns#DISPLAY_NAME}, both during initial
-     * insert and subsequent updates.
-     */
-    @Test
-    public void testName() throws Exception {
-        final String name1 = "Playlist " + System.nanoTime();
-        final String name2 = "Playlist " + System.nanoTime();
-        assertNotEquals(name1, name2);
-
-        mValues.clear();
-        mValues.put(Playlists.NAME, name1);
-        final Uri playlist = mContentResolver.insert(mExternalPlaylists, mValues);
-        try (Cursor c = mContentResolver.query(playlist,
-                new String[] { Playlists.NAME, MediaColumns.DISPLAY_NAME }, null, null)) {
-            assertTrue(c.moveToFirst());
-            assertTrue(c.getString(0).startsWith(name1));
-            assertTrue(c.getString(1).startsWith(name1));
-        }
-
-        mValues.clear();
-        mValues.put(Playlists.NAME, name2);
-        mContentResolver.update(playlist, mValues, null);
-        try (Cursor c = mContentResolver.query(playlist,
-                new String[] { Playlists.NAME, MediaColumns.DISPLAY_NAME }, null, null)) {
-            assertTrue(c.moveToFirst());
-            assertTrue(c.getString(0).startsWith(name2));
-            assertTrue(c.getString(1).startsWith(name2));
-        }
-    }
-
     @Test
     public void testAdd() throws Exception {
         mValues.clear();
diff --git a/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java b/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java
index 89390d3..fa2de15 100644
--- a/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java
+++ b/tests/client/src/com/android/providers/media/client/LegacyProviderMigrationTest.java
@@ -106,6 +106,7 @@
         final ContentValues values = new ContentValues();
         values.put(FileColumns.MEDIA_TYPE, mediaType);
         values.put(MediaColumns.DATA, file.getAbsolutePath());
+        values.put(MediaColumns.DISPLAY_NAME, file.getName());
         values.put(MediaColumns.MIME_TYPE, mimeType);
         values.put(MediaColumns.VOLUME_NAME, mVolumeName);
         values.put(MediaColumns.DATE_ADDED, String.valueOf(System.currentTimeMillis() / 1_000));
@@ -215,6 +216,9 @@
             // Drop media type from the columns we check, since it's implicitly
             // verified via the collection Uri
             values.remove(FileColumns.MEDIA_TYPE);
+
+            // Drop raw path, since we may rename pending or trashed files
+            values.remove(FileColumns.DATA);
         }
 
         // Clear data on the modern provider so that the initial scan recovers
@@ -232,9 +236,9 @@
                 .acquireContentProviderClient(MediaStore.AUTHORITY)) {
             final Bundle extras = new Bundle();
             extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
-                    MediaColumns.DATA + "=?");
+                    MediaColumns.DISPLAY_NAME + "=?");
             extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
-                    new String[] { legacyFile.getAbsolutePath() });
+                    new String[] { legacyFile.getName() });
             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);
diff --git a/tests/src/com/android/providers/media/DatabaseHelperTest.java b/tests/src/com/android/providers/media/DatabaseHelperTest.java
index 1149854..a83cc13 100644
--- a/tests/src/com/android/providers/media/DatabaseHelperTest.java
+++ b/tests/src/com/android/providers/media/DatabaseHelperTest.java
@@ -74,7 +74,7 @@
     @Test
     public void testFilterVolumeNames() throws Exception {
         try (DatabaseHelper helper = new DatabaseHelperR(getContext(), TEST_CLEAN_DB)) {
-            SQLiteDatabase db = helper.getWritableDatabase();
+            SQLiteDatabase db = helper.getWritableDatabaseForTest();
             {
                 final ContentValues values = new ContentValues();
                 values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_AUDIO);
@@ -163,7 +163,7 @@
                 helper.endTransaction();
             }
 
-            helper.runWithTransaction(() -> {
+            helper.runWithTransaction((db) -> {
                 return 0;
             });
         }
@@ -188,7 +188,7 @@
             Class<? extends DatabaseHelper> after) throws Exception {
         try (DatabaseHelper helper = before.getConstructor(Context.class, String.class)
                 .newInstance(getContext(), TEST_DOWNGRADE_DB)) {
-            SQLiteDatabase db = helper.getWritableDatabase();
+            SQLiteDatabase db = helper.getWritableDatabaseForTest();
             {
                 final ContentValues values = new ContentValues();
                 values.put(FileColumns.DATA,
@@ -207,7 +207,7 @@
         // Downgrade will wipe data, but at least we don't crash
         try (DatabaseHelper helper = after.getConstructor(Context.class, String.class)
                 .newInstance(getContext(), TEST_DOWNGRADE_DB)) {
-            SQLiteDatabase db = helper.getWritableDatabase();
+            SQLiteDatabase db = helper.getWritableDatabaseForTest();
             try (Cursor c = db.query("files", null, null, null, null, null, null, null)) {
                 assertEquals(0, c.getCount());
             }
@@ -235,7 +235,7 @@
             Class<? extends DatabaseHelper> after) throws Exception {
         try (DatabaseHelper helper = before.getConstructor(Context.class, String.class)
                 .newInstance(getContext(), TEST_RECOMPUTE_DB)) {
-            SQLiteDatabase db = helper.getWritableDatabase();
+            SQLiteDatabase db = helper.getWritableDatabaseForTest();
             {
                 final ContentValues values = new ContentValues();
                 values.put(FileColumns.DATA,
@@ -299,7 +299,7 @@
 
         try (DatabaseHelper helper = after.getConstructor(Context.class, String.class)
                 .newInstance(getContext(), TEST_RECOMPUTE_DB)) {
-            SQLiteDatabase db = helper.getWritableDatabase();
+            SQLiteDatabase db = helper.getWritableDatabaseForTest();
             try (Cursor c = db.query("files", null, FileColumns.DISPLAY_NAME + "='global.jpg'",
                     null, null, null, null)) {
                 assertEquals(1, c.getCount());
@@ -369,18 +369,18 @@
             Class<? extends DatabaseHelper> after) throws Exception {
         try (DatabaseHelper helper = before.getConstructor(Context.class, String.class)
                 .newInstance(getContext(), TEST_UPGRADE_DB)) {
-            SQLiteDatabase db = helper.getWritableDatabase();
+            SQLiteDatabase db = helper.getWritableDatabaseForTest();
         }
 
         try (DatabaseHelper helper = after.getConstructor(Context.class, String.class)
                 .newInstance(getContext(), TEST_UPGRADE_DB)) {
-            SQLiteDatabase db = helper.getWritableDatabase();
+            SQLiteDatabase db = helper.getWritableDatabaseForTest();
 
             // Create a second isolated instance from scratch and assert that
             // upgraded schema is identical
             try (DatabaseHelper helper2 = after.getConstructor(Context.class, String.class)
                     .newInstance(getContext(), TEST_CLEAN_DB)) {
-                SQLiteDatabase db2 = helper2.getWritableDatabase();
+                SQLiteDatabase db2 = helper2.getWritableDatabaseForTest();
 
                 try (Cursor c1 = db.query("sqlite_master",
                         null, null, null, null, null, SQLITE_MASTER_ORDER_BY);
@@ -413,8 +413,8 @@
 
     private static Set<String> queryValues(@NonNull DatabaseHelper helper, @NonNull String table,
             @NonNull String columnName) {
-        try (Cursor c = helper.getReadableDatabase().query(table, new String[] { columnName },
-                null, null, null, null, null)) {
+        try (Cursor c = helper.getWritableDatabaseForTest().query(table,
+                new String[] { columnName }, null, null, null, null, null)) {
             final ArraySet<String> res = new ArraySet<>();
             while (c.moveToNext()) {
                 res.add(c.getString(0));
@@ -433,11 +433,6 @@
         public void onCreate(SQLiteDatabase db) {
             createOSchema(db, false);
         }
-
-        @Override
-        public void onOpen(SQLiteDatabase db) {
-            // Purposefully empty to leave views intact
-        }
     }
 
     private static class DatabaseHelperP extends DatabaseHelper {
@@ -450,11 +445,6 @@
         public void onCreate(SQLiteDatabase db) {
             createPSchema(db, false);
         }
-
-        @Override
-        public void onOpen(SQLiteDatabase db) {
-            // Purposefully empty to leave views intact
-        }
     }
 
     private static class DatabaseHelperQ extends DatabaseHelper {
@@ -467,11 +457,6 @@
         public void onCreate(SQLiteDatabase db) {
             createQSchema(db, false);
         }
-
-        @Override
-        public void onOpen(SQLiteDatabase db) {
-            // Purposefully empty to leave views intact
-        }
     }
 
     private static class DatabaseHelperR extends DatabaseHelper {
diff --git a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
new file mode 100644
index 0000000..127cb35
--- /dev/null
+++ b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media;
+
+import android.Manifest;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Environment;
+import android.provider.MediaStore;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
+
+import com.google.common.truth.Truth;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.util.Arrays;
+
+/**
+ * This class is purely here to convince internal code coverage tools that
+ * {@code FuseDaemonHostTest} is actually covering all of these methods; the
+ * current coverage infrastructure doesn't support host tests yet.
+ */
+@RunWith(AndroidJUnit4.class)
+public class MediaProviderForFuseTest {
+
+    private static Context sIsolatedContext;
+    private static ContentResolver sIsolatedResolver;
+    private static MediaProvider sMediaProvider;
+
+    private static int sTestUid;
+    private static File sTestDir;
+
+    @BeforeClass
+    public static void setUp() throws Exception {
+        InstrumentationRegistry.getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(
+                Manifest.permission.LOG_COMPAT_CHANGE,
+                Manifest.permission.READ_COMPAT_CHANGE_CONFIG,
+                Manifest.permission.UPDATE_APP_OPS_STATS);
+
+        final Context context = InstrumentationRegistry.getTargetContext();
+        sIsolatedContext = new IsolatedContext(context, "modern");
+        sIsolatedResolver = sIsolatedContext.getContentResolver();
+        sMediaProvider = (MediaProvider) sIsolatedResolver
+                .acquireContentProviderClient(MediaStore.AUTHORITY).getLocalContentProvider();
+
+        // Use a random app without any permissions
+        sTestUid = context.getPackageManager().getPackageUid("com.android.egg",
+                PackageManager.MATCH_ALL);
+        sTestDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+    }
+
+    @AfterClass
+    public static void tearDown() throws Exception {
+        InstrumentationRegistry.getInstrumentation()
+                .getUiAutomation().dropShellPermissionIdentity();
+    }
+
+    @Test
+    public void testTypical() throws Exception {
+        final File file = new File(sTestDir, "test" + System.nanoTime());
+
+        // We can create our file
+        Truth.assertThat(sMediaProvider.insertFileIfNecessaryForFuse(
+                file.getPath(), sTestUid)).isEqualTo(0);
+        Truth.assertThat(Arrays.asList(sMediaProvider.getFilesInDirectoryForFuse(
+                sTestDir.getPath(), sTestUid))).contains(file.getName());
+
+        // Touch on disk so we can rename below
+        file.createNewFile();
+
+        // We can write our file
+        Truth.assertThat(sMediaProvider.isOpenAllowedForFuse(
+                file.getPath(), sTestUid, true)).isEqualTo(0);
+
+        // We should have no redaction
+        Truth.assertThat(sMediaProvider.getRedactionRangesForFuse(
+                file.getPath(), sTestUid)).isEqualTo(new long[0]);
+
+        // We can rename our file
+        final File renamed = new File(sTestDir, "renamed" + System.nanoTime());
+        Truth.assertThat(sMediaProvider.renameForFuse(
+                file.getPath(), renamed.getPath(), sTestUid)).isEqualTo(0);
+        Truth.assertThat(Arrays.asList(sMediaProvider.getFilesInDirectoryForFuse(
+                sTestDir.getPath(), sTestUid))).doesNotContain(file.getName());
+        Truth.assertThat(Arrays.asList(sMediaProvider.getFilesInDirectoryForFuse(
+                sTestDir.getPath(), sTestUid))).contains(renamed.getName());
+
+        // And we can delete it
+        Truth.assertThat(sMediaProvider.deleteFileForFuse(
+                renamed.getPath(), sTestUid)).isEqualTo(0);
+        Truth.assertThat(Arrays.asList(sMediaProvider.getFilesInDirectoryForFuse(
+                sTestDir.getPath(), sTestUid))).doesNotContain(renamed.getName());
+    }
+
+    @Test
+    public void test_scanFileForFuse() throws Exception {
+        final File file = File.createTempFile("test", ".jpg", sTestDir);
+        sMediaProvider.scanFileForFuse(file.getPath());
+    }
+
+    @Test
+    public void test_isOpendirAllowedForFuse() throws Exception {
+        Truth.assertThat(sMediaProvider.isOpendirAllowedForFuse(
+                sTestDir.getPath(), sTestUid)).isEqualTo(0);
+    }
+
+    @Test
+    public void test_isDirectoryCreationOrDeletionAllowedForFuse() throws Exception {
+        Truth.assertThat(sMediaProvider.isDirectoryCreationOrDeletionAllowedForFuse(
+                sTestDir.getPath(), sTestUid, true)).isEqualTo(0);
+    }
+}
diff --git a/tests/src/com/android/providers/media/MediaProviderTest.java b/tests/src/com/android/providers/media/MediaProviderTest.java
index 304124f..f4fa45d 100644
--- a/tests/src/com/android/providers/media/MediaProviderTest.java
+++ b/tests/src/com/android/providers/media/MediaProviderTest.java
@@ -34,7 +34,6 @@
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
-import android.os.CancellationSignal;
 import android.os.Environment;
 import android.provider.MediaStore;
 import android.provider.MediaStore.Images.ImageColumns;
@@ -51,8 +50,8 @@
 import com.android.providers.media.util.FileUtils;
 import com.android.providers.media.util.SQLiteQueryBuilder;
 
-import org.junit.After;
-import org.junit.Before;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -66,25 +65,28 @@
 public class MediaProviderTest {
     static final String TAG = "MediaProviderTest";
 
-    @Before
-    public void setUp() {
+    private static Context sIsolatedContext;
+    private static ContentResolver sIsolatedResolver;
+
+    @BeforeClass
+    public static void setUp() {
         InstrumentationRegistry.getInstrumentation().getUiAutomation()
                 .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE,
                         Manifest.permission.READ_COMPAT_CHANGE_CONFIG);
+
+        final Context context = InstrumentationRegistry.getTargetContext();
+        sIsolatedContext = new IsolatedContext(context, "modern");
+        sIsolatedResolver = sIsolatedContext.getContentResolver();
     }
 
-    @After
-    public void tearDown() {
+    @AfterClass
+    public static void tearDown() {
         InstrumentationRegistry.getInstrumentation()
                 .getUiAutomation().dropShellPermissionIdentity();
     }
 
     @Test
     public void testSchema() {
-        final Context context = InstrumentationRegistry.getTargetContext();
-        final Context isolatedContext = new IsolatedContext(context, "modern");
-        final ContentResolver isolatedResolver = isolatedContext.getContentResolver();
-
         for (String path : new String[] {
                 "images/media",
                 "images/media/1",
@@ -123,11 +125,11 @@
         }) {
             final Uri probe = MediaStore.AUTHORITY_URI.buildUpon()
                     .appendPath(MediaStore.VOLUME_EXTERNAL).appendEncodedPath(path).build();
-            try (Cursor c = isolatedResolver.query(probe, null, null, null)) {
+            try (Cursor c = sIsolatedResolver.query(probe, null, null, null)) {
                 assertNotNull("probe", c);
             }
             try {
-                isolatedResolver.getType(probe);
+                sIsolatedResolver.getType(probe);
             } catch (IllegalStateException tolerated) {
             }
         }
@@ -135,11 +137,7 @@
 
     @Test
     public void testLocale() {
-        final Context context = InstrumentationRegistry.getTargetContext();
-        final Context isolatedContext = new IsolatedContext(context, "modern");
-        final ContentResolver isolatedResolver = isolatedContext.getContentResolver();
-
-        try (ContentProviderClient cpc = isolatedResolver
+        try (ContentProviderClient cpc = sIsolatedResolver
                 .acquireContentProviderClient(MediaStore.AUTHORITY)) {
             ((MediaProvider) cpc.getLocalContentProvider())
                     .onLocaleChanged();
@@ -310,6 +308,78 @@
     }
 
     @Test
+    public void testBuildData_Pending_FromValues() throws Exception {
+        final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+        final ContentValues forward = new ContentValues();
+        forward.put(MediaColumns.RELATIVE_PATH, "DCIM/My Vacation/");
+        forward.put(MediaColumns.DISPLAY_NAME, "IMG1024.JPG");
+        forward.put(MediaColumns.MIME_TYPE, "image/jpeg");
+        forward.put(MediaColumns.IS_PENDING, 1);
+        forward.put(MediaColumns.IS_TRASHED, 0);
+        forward.put(MediaColumns.DATE_EXPIRES, 1577836800L);
+        ensureFileColumns(uri, forward);
+
+        // Requested filename remains intact, but raw path on disk is mutated to
+        // reflect that it's a pending item with a specific expiration time
+        assertEquals("IMG1024.JPG",
+                forward.getAsString(MediaColumns.DISPLAY_NAME));
+        assertEndsWith(".pending-1577836800-IMG1024.JPG",
+                forward.getAsString(MediaColumns.DATA));
+    }
+
+    @Test
+    public void testBuildData_Pending_FromData() throws Exception {
+        final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+        final ContentValues reverse = new ContentValues();
+        reverse.put(MediaColumns.DATA,
+                "/storage/emulated/0/DCIM/My Vacation/.pending-1577836800-IMG1024.JPG");
+        ensureFileColumns(uri, reverse);
+
+        assertEquals("DCIM/My Vacation/", reverse.getAsString(MediaColumns.RELATIVE_PATH));
+        assertEquals("IMG1024.JPG", reverse.getAsString(MediaColumns.DISPLAY_NAME));
+        assertEquals("image/jpeg", reverse.getAsString(MediaColumns.MIME_TYPE));
+        assertEquals(1, (int) reverse.getAsInteger(MediaColumns.IS_PENDING));
+        assertEquals(0, (int) reverse.getAsInteger(MediaColumns.IS_TRASHED));
+        assertEquals(1577836800, (long) reverse.getAsLong(MediaColumns.DATE_EXPIRES));
+    }
+
+    @Test
+    public void testBuildData_Trashed_FromValues() throws Exception {
+        final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+        final ContentValues forward = new ContentValues();
+        forward.put(MediaColumns.RELATIVE_PATH, "DCIM/My Vacation/");
+        forward.put(MediaColumns.DISPLAY_NAME, "IMG1024.JPG");
+        forward.put(MediaColumns.MIME_TYPE, "image/jpeg");
+        forward.put(MediaColumns.IS_PENDING, 0);
+        forward.put(MediaColumns.IS_TRASHED, 1);
+        forward.put(MediaColumns.DATE_EXPIRES, 1577836800L);
+        ensureFileColumns(uri, forward);
+
+        // Requested filename remains intact, but raw path on disk is mutated to
+        // reflect that it's a trashed item with a specific expiration time
+        assertEquals("IMG1024.JPG",
+                forward.getAsString(MediaColumns.DISPLAY_NAME));
+        assertEndsWith(".trashed-1577836800-IMG1024.JPG",
+                forward.getAsString(MediaColumns.DATA));
+    }
+
+    @Test
+    public void testBuildData_Trashed_FromData() throws Exception {
+        final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
+        final ContentValues reverse = new ContentValues();
+        reverse.put(MediaColumns.DATA,
+                "/storage/emulated/0/DCIM/My Vacation/.trashed-1577836800-IMG1024.JPG");
+        ensureFileColumns(uri, reverse);
+
+        assertEquals("DCIM/My Vacation/", reverse.getAsString(MediaColumns.RELATIVE_PATH));
+        assertEquals("IMG1024.JPG", reverse.getAsString(MediaColumns.DISPLAY_NAME));
+        assertEquals("image/jpeg", reverse.getAsString(MediaColumns.MIME_TYPE));
+        assertEquals(0, (int) reverse.getAsInteger(MediaColumns.IS_PENDING));
+        assertEquals(1, (int) reverse.getAsInteger(MediaColumns.IS_TRASHED));
+        assertEquals(1577836800, (long) reverse.getAsLong(MediaColumns.DATE_EXPIRES));
+    }
+
+    @Test
     public void testGreylist() throws Exception {
         assertFalse(isGreylistMatch(
                 "SELECT secret FROM other_table"));
@@ -615,7 +685,7 @@
     private static ContentValues computeDataValues(String path) {
         final ContentValues values = new ContentValues();
         values.put(MediaColumns.DATA, path);
-        FileUtils.computeDataValues(values);
+        FileUtils.computeValuesFromData(values);
         Log.v(TAG, "Computed values " + values);
         return values;
     }
@@ -653,7 +723,7 @@
         return false;
     }
 
-    private static String buildFile(Uri uri, String relativePath, String displayName,
+    private String buildFile(Uri uri, String relativePath, String displayName,
             String mimeType) {
         final ContentValues values = new ContentValues();
         if (relativePath != null) {
@@ -662,13 +732,22 @@
         values.put(MediaColumns.DISPLAY_NAME, displayName);
         values.put(MediaColumns.MIME_TYPE, mimeType);
         try {
-            new MediaProvider().ensureFileColumns(uri, values);
+            ensureFileColumns(uri, values);
         } catch (VolumeArgumentException e) {
             throw e.rethrowAsIllegalArgumentException();
         }
         return values.getAsString(MediaColumns.DATA);
     }
 
+    private void ensureFileColumns(Uri uri, ContentValues values)
+            throws VolumeArgumentException {
+        try (ContentProviderClient cpc = sIsolatedResolver
+                .acquireContentProviderClient(MediaStore.AUTHORITY)) {
+            ((MediaProvider) cpc.getLocalContentProvider())
+                    .ensureFileColumns(uri, values);
+        }
+    }
+
     private static void assertEndsWith(String expected, String actual) {
         if (!actual.endsWith(expected)) {
             fail("Expected ends with " + expected + " but found " + actual);
diff --git a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
index ff78be2..448ed2c 100644
--- a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
@@ -20,6 +20,7 @@
 import static com.android.providers.media.scan.MediaScannerTest.stage;
 import static com.android.providers.media.scan.ModernMediaScanner.isDirectoryHidden;
 import static com.android.providers.media.scan.ModernMediaScanner.isFileAlbumArt;
+import static com.android.providers.media.scan.ModernMediaScanner.isFileHidden;
 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalDateTaken;
 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalMimeType;
 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalYear;
@@ -289,6 +290,18 @@
     }
 
     @Test
+    public void testIsFileHidden() throws Exception {
+        assertFalse(isFileHidden(
+                new File("/storage/emulated/0/DCIM/IMG1024.JPG")));
+        assertFalse(isFileHidden(
+                new File("/storage/emulated/0/DCIM/.pending-1577836800-IMG1024.JPG")));
+        assertFalse(isFileHidden(
+                new File("/storage/emulated/0/DCIM/.trashed-1577836800-IMG1024.JPG")));
+        assertTrue(isFileHidden(
+                new File("/storage/emulated/0/DCIM/.IMG1024.JPG")));
+    }
+
+    @Test
     public void testIsZero() throws Exception {
         assertFalse(ModernMediaScanner.isZero(""));
         assertFalse(ModernMediaScanner.isZero("meow"));
@@ -587,8 +600,7 @@
                 "/storage/emulated/0/albumart1.jpg",
         }) {
             final File file = new File(path);
-            final String name = file.getName();
-            assertEquals(LegacyMediaScannerTest.isNonMediaFile(path), isFileAlbumArt(name));
+            assertEquals(LegacyMediaScannerTest.isNonMediaFile(path), isFileAlbumArt(file));
         }
 
         for (String path : new String[] {
@@ -596,8 +608,7 @@
                 "/storage/emulated/0/albumartlarge.jpg",
         }) {
             final File file = new File(path);
-            final String name = file.getName();
-            assertTrue(isFileAlbumArt(name));
+            assertTrue(isFileAlbumArt(file));
         }
     }
 }
diff --git a/tests/src/com/android/providers/media/util/FileUtilsTest.java b/tests/src/com/android/providers/media/util/FileUtilsTest.java
index ec0296f..5ae7384 100644
--- a/tests/src/com/android/providers/media/util/FileUtilsTest.java
+++ b/tests/src/com/android/providers/media/util/FileUtilsTest.java
@@ -24,12 +24,19 @@
 import static com.android.providers.media.util.FileUtils.extractDisplayName;
 import static com.android.providers.media.util.FileUtils.extractFileExtension;
 import static com.android.providers.media.util.FileUtils.extractFileName;
+import static com.android.providers.media.util.FileUtils.extractRelativePath;
+import static com.android.providers.media.util.FileUtils.extractVolumeName;
+import static com.android.providers.media.util.FileUtils.extractVolumePath;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
+import android.content.ContentValues;
+import android.provider.MediaStore;
+import android.provider.MediaStore.MediaColumns;
+
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
@@ -248,6 +255,15 @@
     }
 
     @Test
+    public void testBuildUniqueFile_increment_hidden() throws Exception {
+        assertNameEquals(".hidden.jpg",
+                FileUtils.buildUniqueFile(mTarget, "image/jpeg", ".hidden.jpg"));
+        new File(mTarget, ".hidden.jpg").createNewFile();
+        assertNameEquals(".hidden (1).jpg",
+                FileUtils.buildUniqueFile(mTarget, "image/jpeg", ".hidden.jpg"));
+    }
+
+    @Test
     public void testBuildUniqueFile_mimeless() throws Exception {
         assertNameEquals("test.jpg", FileUtils.buildUniqueFile(mTarget, "test.jpg"));
         new File(mTarget, "test.jpg").createNewFile();
@@ -311,6 +327,37 @@
     }
 
     @Test
+    public void testExtractVolumePath() throws Exception {
+        assertEquals("/storage/emulated/0/",
+                extractVolumePath("/storage/emulated/0/foo.jpg"));
+        assertEquals("/storage/0000-0000/",
+                extractVolumePath("/storage/0000-0000/foo.jpg"));
+    }
+
+    @Test
+    public void testExtractVolumeName() throws Exception {
+        assertEquals(MediaStore.VOLUME_EXTERNAL_PRIMARY,
+                extractVolumeName("/storage/emulated/0/foo.jpg"));
+        assertEquals("0000-0000",
+                extractVolumeName("/storage/0000-0000/foo.jpg"));
+    }
+
+    @Test
+    public void testExtractRelativePath() throws Exception {
+        for (String prefix : new String[] {
+                "/storage/emulated/0/",
+                "/storage/0000-0000/"
+        }) {
+            assertEquals("/",
+                    extractRelativePath(prefix + "foo.jpg"));
+            assertEquals("DCIM/",
+                    extractRelativePath(prefix + "DCIM/foo.jpg"));
+            assertEquals("DCIM/My Vacation/",
+                    extractRelativePath(prefix + "DCIM/My Vacation/foo.jpg"));
+        }
+    }
+
+    @Test
     public void testExtractDisplayName() throws Exception {
         for (String probe : new String[] {
                 "foo.bar.baz",
@@ -391,6 +438,24 @@
         }
     }
 
+    @Test
+    public void testSanitizeValues() throws Exception {
+        final ContentValues values = new ContentValues();
+        values.put(MediaColumns.RELATIVE_PATH, "path/in\0valid/data/");
+        values.put(MediaColumns.DISPLAY_NAME, "inva\0lid");
+        FileUtils.sanitizeValues(values);
+        assertEquals("path/in_valid/data/", values.get(MediaColumns.RELATIVE_PATH));
+        assertEquals("inva_lid", values.get(MediaColumns.DISPLAY_NAME));
+    }
+
+    @Test
+    public void testSanitizeValues_Root() throws Exception {
+        final ContentValues values = new ContentValues();
+        values.put(MediaColumns.RELATIVE_PATH, "/");
+        FileUtils.sanitizeValues(values);
+        assertEquals("/", values.get(MediaColumns.RELATIVE_PATH));
+    }
+
     private static File touch(File dir, String name) throws IOException {
         final File res = new File(dir, name);
         res.createNewFile();
diff --git a/tests/src/com/android/providers/media/util/MimeUtilsTest.java b/tests/src/com/android/providers/media/util/MimeUtilsTest.java
index ab2b9d7..2334d5c 100644
--- a/tests/src/com/android/providers/media/util/MimeUtilsTest.java
+++ b/tests/src/com/android/providers/media/util/MimeUtilsTest.java
@@ -37,6 +37,8 @@
     public void testResolveMimeType() throws Exception {
         assertEquals("image/jpeg",
                 MimeUtils.resolveMimeType(new File("foo.jpg")));
+        assertEquals("image/jpeg",
+                MimeUtils.resolveMimeType(new File(".hidden.jpg")));
 
         assertEquals(ClipDescription.MIMETYPE_UNKNOWN,
                 MimeUtils.resolveMimeType(new File("foo")));