Merge changes from topic "mar31" into rvc-dev

* changes:
  Introduce database schema locking.
  Progress towards database schema locking.
diff --git a/src/com/android/providers/media/DatabaseHelper.java b/src/com/android/providers/media/DatabaseHelper.java
index 8902020..3913065 100644
--- a/src/com/android/providers/media/DatabaseHelper.java
+++ b/src/com/android/providers/media/DatabaseHelper.java
@@ -776,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
@@ -794,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);
             }
         }
@@ -1161,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);
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index b5fe5b5..04826c9 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;
@@ -1391,7 +1389,7 @@
             computeAudioLocalizedValues(values);
             computeAudioKeyValues(values);
         }
-        computeDataValues(values);
+        FileUtils.computeValuesFromData(values);
         return values;
     }
 
@@ -2119,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))) {
@@ -2184,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
@@ -2229,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);
             }
@@ -2289,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;
         }
 
@@ -2555,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);
@@ -2952,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));
                     }
@@ -4617,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));
@@ -4644,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
@@ -4757,7 +4755,7 @@
             case IMAGES_MEDIA_ID:
             case FILES_ID:
             case DOWNLOADS_ID: {
-                computeDataValues(values);
+                FileUtils.computeValuesFromData(values);
                 break;
             }
         }
@@ -6787,6 +6785,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/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/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/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")));