More progress towards building against APIs.

Some common utility methods on FileUtils and DatabaseUtils
technically don't belong in the OS, so clone them locally along
with unit tests.  Switch to androidx version of MimeTypeFilter.

Bump database version to clear out all primary/secondary directory
values, which was replaced by new RELATIVE_PATH column before Q
launched.

Bug: 137890034
Test: atest --test-mapping packages/providers/MediaProvider
Change-Id: I5cc4891a8bf6188ab34e4225bde0a4ec4f994895
diff --git a/Android.bp b/Android.bp
index 656c864..783da9e 100644
--- a/Android.bp
+++ b/Android.bp
@@ -5,6 +5,7 @@
 
     static_libs: [
         "androidx.appcompat_appcompat",
+        "androidx.core_core",
     ],
 
     resource_dirs: [
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index cfa0c08..52ade45 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -2,7 +2,7 @@
         package="com.android.providers.media"
         android:sharedUserId="android.media"
         android:sharedUserLabel="@string/uid_label"
-        android:versionCode="1100">
+        android:versionCode="1101">
 
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
     <uses-permission android:name="android.permission.RECEIVE_DEVICE_CUSTOMIZATION_READY" />
diff --git a/src/com/android/providers/media/MediaDocumentsProvider.java b/src/com/android/providers/media/MediaDocumentsProvider.java
index 40d2ec3..083b718 100644
--- a/src/com/android/providers/media/MediaDocumentsProvider.java
+++ b/src/com/android/providers/media/MediaDocumentsProvider.java
@@ -22,7 +22,6 @@
 import android.content.ContentUris;
 import android.content.Context;
 import android.content.Intent;
-import android.content.MimeTypeFilter;
 import android.content.res.AssetFileDescriptor;
 import android.database.Cursor;
 import android.database.MatrixCursor;
@@ -35,7 +34,6 @@
 import android.os.Binder;
 import android.os.Bundle;
 import android.os.CancellationSignal;
-import android.os.FileUtils;
 import android.os.IBinder;
 import android.os.ParcelFileDescriptor;
 import android.os.UserHandle;
@@ -65,8 +63,10 @@
 import android.util.Pair;
 
 import androidx.annotation.Nullable;
+import androidx.core.content.MimeTypeFilter;
 
 import com.android.providers.media.util.BackgroundThread;
+import com.android.providers.media.util.FileUtils;
 
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -184,7 +184,8 @@
     }
 
     private void enforceShellRestrictions() {
-        if (UserHandle.getCallingAppId() == android.os.Process.SHELL_UID
+        final int callingAppId = UserHandle.getAppId(Binder.getCallingUid());
+        if (callingAppId == android.os.Process.SHELL_UID
                 && getContext().getSystemService(UserManager.class)
                         .hasUserRestriction(UserManager.DISALLOW_USB_FILE_TRANSFER)) {
             throw new SecurityException(
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 44e66c6..6803938 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -20,7 +20,6 @@
 import static android.app.PendingIntent.FLAG_IMMUTABLE;
 import static android.app.PendingIntent.FLAG_ONE_SHOT;
 import static android.content.pm.PackageManager.PERMISSION_GRANTED;
-import static android.os.Environment.buildPath;
 import static android.provider.MediaStore.AUTHORITY;
 import static android.provider.MediaStore.getVolumeName;
 import static android.provider.MediaStore.Downloads.PATTERN_DOWNLOADS_FILE;
@@ -36,13 +35,13 @@
 import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_IMAGES;
 import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_VIDEO;
 
-import android.annotation.BytesLong;
 import android.app.AppOpsManager;
 import android.app.AppOpsManager.OnOpActiveChangedListener;
 import android.app.PendingIntent;
 import android.app.RecoverableSecurityException;
 import android.app.RemoteAction;
 import android.content.BroadcastReceiver;
+import android.content.ClipDescription;
 import android.content.ContentProvider;
 import android.content.ContentProviderClient;
 import android.content.ContentProviderOperation;
@@ -64,7 +63,6 @@
 import android.content.res.Resources;
 import android.database.AbstractCursor;
 import android.database.Cursor;
-import android.database.DatabaseUtils;
 import android.database.MatrixCursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteOpenHelper;
@@ -82,7 +80,6 @@
 import android.os.Bundle;
 import android.os.CancellationSignal;
 import android.os.Environment;
-import android.os.FileUtils;
 import android.os.IBinder;
 import android.os.ParcelFileDescriptor;
 import android.os.ParcelFileDescriptor.OnCloseListener;
@@ -137,6 +134,8 @@
 import com.android.providers.media.scan.ModernMediaScanner;
 import com.android.providers.media.util.BackgroundThread;
 import com.android.providers.media.util.CachedSupplier;
+import com.android.providers.media.util.DatabaseUtils;
+import com.android.providers.media.util.FileUtils;
 import com.android.providers.media.util.IsoInterface;
 import com.android.providers.media.util.LongArray;
 import com.android.providers.media.util.XmpInterface;
@@ -738,9 +737,6 @@
     public boolean onCreate() {
         final Context context = getContext();
 
-        // Enable verbose transport logging when requested
-        setTransportLoggingEnabled(LOCAL_LOGV);
-
         // Shift call statistics back to the original caller
         Binder.setProxyTransactListener(
                 new Binder.PropagateWorkSourceTransactListener());
@@ -784,9 +780,9 @@
         }
 
         // Watch for performance-sensitive activity
-        mAppOpsManager.startWatchingActive(new int[] {
-                AppOpsManager.OP_CAMERA
-        }, mActiveListener);
+        mAppOpsManager.startWatchingActive(new String[] {
+                AppOpsManager.OPSTR_CAMERA
+        }, context.getMainExecutor(), mActiveListener);
 
         return true;
     }
@@ -905,7 +901,8 @@
     }
 
     private void enforceShellRestrictions() {
-        if (UserHandle.getCallingAppId() == android.os.Process.SHELL_UID
+        final int callingAppId = UserHandle.getAppId(Binder.getCallingUid());
+        if (callingAppId == android.os.Process.SHELL_UID
                 && getContext().getSystemService(UserManager.class)
                         .hasUserRestriction(UserManager.DISALLOW_USB_FILE_TRANSFER)) {
             throw new SecurityException(
@@ -1272,6 +1269,10 @@
                 + " AND " + MediaColumns.RELATIVE_PATH + " NOT LIKE '%/';");
     }
 
+    private static void updateClearDirectories(SQLiteDatabase db, boolean internal) {
+        db.execSQL("UPDATE files SET primary_directory=NULL, secondary_directory=NULL;");
+    }
+
     private static void recomputeDataValues(SQLiteDatabase db, boolean internal) {
         try (Cursor c = db.query("files", new String[] { FileColumns._ID, FileColumns.DATA },
                 null, null, null, null, null, null)) {
@@ -1300,7 +1301,7 @@
     static final int VERSION_O = 800;
     static final int VERSION_P = 900;
     static final int VERSION_Q = 1023;
-    static final int VERSION_R = 1100;
+    static final int VERSION_R = 1101;
 
     /**
      * This method takes care of updating all the tables in the database to the
@@ -1401,6 +1402,9 @@
             if (fromVersion < 1100) {
                 // Empty version bump to ensure triggers are recreated
             }
+            if (fromVersion < 1101) {
+                updateClearDirectories(db, internal);
+            }
 
             if (recomputeDataValues) {
                 recomputeDataValues(db, internal);
@@ -1508,8 +1512,6 @@
         values.remove(ImageColumns.GROUP_ID);
         values.remove(ImageColumns.VOLUME_NAME);
         values.remove(ImageColumns.RELATIVE_PATH);
-        values.remove(ImageColumns.PRIMARY_DIRECTORY);
-        values.remove(ImageColumns.SECONDARY_DIRECTORY);
 
         final String data = values.getAsString(MediaColumns.DATA);
         if (TextUtils.isEmpty(data)) return;
@@ -1538,18 +1540,6 @@
             values.put(ImageColumns.GROUP_ID,
                     name.substring(0, firstDot).hashCode());
         }
-
-        // Directories are first two levels of storage paths
-        final String relativePath = values.getAsString(ImageColumns.RELATIVE_PATH);
-        if (TextUtils.isEmpty(relativePath)) return;
-
-        final String[] segments = relativePath.split("/");
-        if (segments.length > 0) {
-            values.put(ImageColumns.PRIMARY_DIRECTORY, segments[0]);
-        }
-        if (segments.length > 1) {
-            values.put(ImageColumns.SECONDARY_DIRECTORY, segments[1]);
-        }
     }
 
     @Override
@@ -1984,7 +1974,7 @@
         Trace.beginSection("ensureFileColumns");
 
         // Figure out defaults based on Uri being modified
-        String defaultMimeType = ContentResolver.MIME_TYPE_DEFAULT;
+        String defaultMimeType = ClipDescription.MIMETYPE_UNKNOWN;
         String defaultPrimary = Environment.DIRECTORY_DOWNLOADS;
         String defaultSecondary = null;
         List<String> allowedPrimary = Arrays.asList(
@@ -2094,7 +2084,7 @@
 
         // Sanity check MIME type against table
         final String mimeType = values.getAsString(MediaColumns.MIME_TYPE);
-        if (mimeType != null && !defaultMimeType.equals(ContentResolver.MIME_TYPE_DEFAULT)) {
+        if (mimeType != null && !defaultMimeType.equals(ClipDescription.MIMETYPE_UNKNOWN)) {
             final String[] split = defaultMimeType.split("/");
             if (!mimeType.startsWith(split[0])) {
                 throw new IllegalArgumentException(
@@ -2105,20 +2095,14 @@
 
         // Generate path when undefined
         if (TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) {
-            // Combine together deprecated columns when path undefined
             if (TextUtils.isEmpty(values.getAsString(MediaColumns.RELATIVE_PATH))) {
-                String primary = values.getAsString(MediaColumns.PRIMARY_DIRECTORY);
-                String secondary = values.getAsString(MediaColumns.SECONDARY_DIRECTORY);
-
-                // Fall back to defaults when caller left undefined
-                if (TextUtils.isEmpty(primary)) primary = defaultPrimary;
-                if (TextUtils.isEmpty(secondary)) secondary = defaultSecondary;
-
-                if (primary != null) {
-                    if (secondary != null) {
-                        values.put(MediaColumns.RELATIVE_PATH, primary + '/' + secondary + '/');
+                if (defaultPrimary != null) {
+                    if (defaultSecondary != null) {
+                        values.put(MediaColumns.RELATIVE_PATH,
+                                defaultPrimary + '/' + defaultSecondary + '/');
                     } else {
-                        values.put(MediaColumns.RELATIVE_PATH, primary + '/');
+                        values.put(MediaColumns.RELATIVE_PATH,
+                                defaultPrimary + '/');
                     }
                 }
             }
@@ -2135,7 +2119,7 @@
             } catch (FileNotFoundException e) {
                 throw new IllegalArgumentException(e);
             }
-            res = Environment.buildPath(res, relativePath);
+            res = FileUtils.buildPath(res, relativePath);
             try {
                 if (makeUnique) {
                     res = FileUtils.buildUniqueFile(res, mimeType, displayName);
@@ -2261,8 +2245,8 @@
     }
 
     private int playlistBulkInsert(SQLiteDatabase db, Uri uri, ContentValues values[]) {
-        DatabaseUtils.InsertHelper helper =
-            new DatabaseUtils.InsertHelper(db, "audio_playlists_map");
+        android.database.DatabaseUtils.InsertHelper helper =
+            new android.database.DatabaseUtils.InsertHelper(db, "audio_playlists_map");
         int audioidcolidx = helper.getColumnIndex(MediaStore.Audio.Playlists.Members.AUDIO_ID);
         int playlistididx = helper.getColumnIndex(Audio.Playlists.Members.PLAYLIST_ID);
         int playorderidx = helper.getColumnIndex(MediaStore.Audio.Playlists.Members.PLAY_ORDER);
@@ -2964,7 +2948,7 @@
                 return e.translateForInsert(targetSdkVersion);
             }
 
-            helper.mScanStartTime = SystemClock.currentTimeMicro();
+            helper.mScanStartTime = SystemClock.elapsedRealtime();
             return MediaStore.getMediaScannerUri();
         }
 
@@ -2979,7 +2963,7 @@
                 } catch (VolumeNotFoundException e) {
                     return e.translateForInsert(targetSdkVersion);
                 }
-                helper.mScanStartTime = SystemClock.currentTimeMicro();
+                helper.mScanStartTime = SystemClock.elapsedRealtime();
             }
             return attachedVolume;
         }
@@ -3441,10 +3425,8 @@
         final boolean allowLegacy = checkCallingPermissionLegacy(uri, forWrite, callingPackage);
         final boolean allowLegacyRead = allowLegacy && !forWrite;
 
-        boolean includePending = parseBoolean(
-                uri.getQueryParameter(MediaStore.PARAM_INCLUDE_PENDING));
-        boolean includeTrashed = parseBoolean(
-                uri.getQueryParameter(MediaStore.PARAM_INCLUDE_TRASHED));
+        boolean includePending = MediaStore.getIncludePending(uri);
+        boolean includeTrashed = false;
         boolean includeAllVolumes = false;
 
         switch (match) {
@@ -3955,7 +3937,7 @@
                 return e.translateForUpdateDelete(targetSdkVersion);
             }
 
-            helper.mScanStopTime = SystemClock.currentTimeMicro();
+            helper.mScanStopTime = SystemClock.elapsedRealtime();
             String msg = dump(helper, false);
             logToDb(helper.getWritableDatabase(), msg);
 
@@ -4338,7 +4320,7 @@
      * package. The meaning of "contributed" means it won't automatically be
      * deleted when the app is uninstalled.
      */
-    private @BytesLong long forEachContributedMedia(String packageName, Consumer<Uri> consumer) {
+    private long forEachContributedMedia(String packageName, Consumer<Uri> consumer) {
         final DatabaseHelper helper = mExternalDatabase;
         final SQLiteDatabase db = helper.getReadableDatabase();
 
@@ -4402,14 +4384,15 @@
 
             // Reconcile all thumbnails, deleting stale items
             for (File thumbDir : new File[] {
-                    buildPath(volumePath, Environment.DIRECTORY_MUSIC, ".thumbnails"),
-                    buildPath(volumePath, Environment.DIRECTORY_MOVIES, ".thumbnails"),
-                    buildPath(volumePath, Environment.DIRECTORY_PICTURES, ".thumbnails"),
+                    FileUtils.buildPath(volumePath, Environment.DIRECTORY_MUSIC, ".thumbnails"),
+                    FileUtils.buildPath(volumePath, Environment.DIRECTORY_MOVIES, ".thumbnails"),
+                    FileUtils.buildPath(volumePath, Environment.DIRECTORY_PICTURES, ".thumbnails"),
             }) {
                 // Possibly bail before digging into each directory
                 signal.throwIfCanceled();
 
-                for (File thumbFile : FileUtils.listFilesOrEmpty(thumbDir)) {
+                final File[] files = thumbDir.listFiles();
+                for (File thumbFile : (files != null) ? files : new File[0]) {
                     final String name = ModernMediaScanner.extractName(thumbFile);
                     try {
                         final long id = Long.parseLong(name);
@@ -4443,7 +4426,7 @@
         private File getThumbnailFile(Uri uri) throws IOException {
             final String volumeName = resolveVolumeName(uri);
             final File volumePath = getVolumePath(volumeName);
-            return Environment.buildPath(volumePath, directoryName,
+            return FileUtils.buildPath(volumePath, directoryName,
                     ".thumbnails", ContentUris.parseId(uri) + ".jpg");
         }
 
@@ -5426,8 +5409,7 @@
 
         // Yell if caller requires original, since we can't give it to them
         // unless they have access granted above
-        if (redactionNeeded
-                && parseBoolean(uri.getQueryParameter(MediaStore.PARAM_REQUIRE_ORIGINAL))) {
+        if (redactionNeeded && MediaStore.getRequireOriginal(uri)) {
             throw new UnsupportedOperationException(
                     "Caller must hold ACCESS_MEDIA_LOCATION permission to access original");
         }
@@ -6152,8 +6134,8 @@
      * $ adb shell setprop db.log.bindargs 1
      */
 
-    static final String TAG = "MediaProvider";
-    static final boolean LOCAL_LOGV = Log.isLoggable(TAG, Log.VERBOSE);
+    public static final String TAG = "MediaProvider";
+    public static final boolean LOCAL_LOGV = Log.isLoggable(TAG, Log.VERBOSE);
 
     private static final String INTERNAL_DATABASE_NAME = "internal.db";
     private static final String EXTERNAL_DATABASE_NAME = "external.db";
@@ -6358,8 +6340,6 @@
         sMutableColumns.add(MediaStore.MediaColumns.IS_PENDING);
         sMutableColumns.add(MediaStore.MediaColumns.IS_TRASHED);
         sMutableColumns.add(MediaStore.MediaColumns.DATE_EXPIRES);
-        sMutableColumns.add(MediaStore.MediaColumns.PRIMARY_DIRECTORY);
-        sMutableColumns.add(MediaStore.MediaColumns.SECONDARY_DIRECTORY);
 
         sMutableColumns.add(MediaStore.Audio.AudioColumns.BOOKMARK);
 
@@ -6385,8 +6365,6 @@
         sPlacementColumns.add(MediaStore.MediaColumns.RELATIVE_PATH);
         sPlacementColumns.add(MediaStore.MediaColumns.DISPLAY_NAME);
         sPlacementColumns.add(MediaStore.MediaColumns.MIME_TYPE);
-        sPlacementColumns.add(MediaStore.MediaColumns.PRIMARY_DIRECTORY);
-        sPlacementColumns.add(MediaStore.MediaColumns.SECONDARY_DIRECTORY);
     }
 
     /**
@@ -6618,16 +6596,16 @@
             }
             if (dbh.mScanStartTime != 0) {
                 s.append("scan started " + DateUtils.formatDateTime(getContext(),
-                        dbh.mScanStartTime / 1000,
+                        dbh.mScanStartTime,
                         DateUtils.FORMAT_SHOW_DATE
                         | DateUtils.FORMAT_SHOW_TIME
                         | DateUtils.FORMAT_ABBREV_ALL));
                 long now = dbh.mScanStopTime;
                 if (now < dbh.mScanStartTime) {
-                    now = SystemClock.currentTimeMicro();
+                    now = SystemClock.elapsedRealtime();
                 }
                 s.append(" (" + DateUtils.formatElapsedTime(
-                        (now - dbh.mScanStartTime) / 1000000) + ")");
+                        (now - dbh.mScanStartTime) / 1_000) + ")");
                 if (dbh.mScanStopTime < dbh.mScanStartTime) {
                     if (mMediaScannerVolume != null &&
                             dbh.mName.startsWith(mMediaScannerVolume)) {
diff --git a/src/com/android/providers/media/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java
index 683fb6c..07310c9 100644
--- a/src/com/android/providers/media/scan/ModernMediaScanner.java
+++ b/src/com/android/providers/media/scan/ModernMediaScanner.java
@@ -38,8 +38,6 @@
 import static android.text.format.DateUtils.HOUR_IN_MILLIS;
 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
 
-import android.annotation.CurrentTimeMillisLong;
-import android.annotation.CurrentTimeSecondsLong;
 import android.content.ContentProviderClient;
 import android.content.ContentProviderOperation;
 import android.content.ContentProviderResult;
@@ -57,7 +55,6 @@
 import android.os.Build;
 import android.os.CancellationSignal;
 import android.os.Environment;
-import android.os.FileUtils;
 import android.os.OperationCanceledException;
 import android.os.RemoteException;
 import android.os.Trace;
@@ -77,6 +74,7 @@
 import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 
+import com.android.providers.media.util.FileUtils;
 import com.android.providers.media.util.IsoInterface;
 import com.android.providers.media.util.LongArray;
 import com.android.providers.media.util.XmpInterface;
@@ -765,11 +763,11 @@
     private static @NonNull ContentProviderOperation.Builder newUpsert(Uri uri, long existingId) {
         if (existingId == -1) {
             return ContentProviderOperation.newInsert(uri)
-                    .withFailureAllowed(true);
+                    .withExceptionAllowed(true);
         } else {
             return ContentProviderOperation.newUpdate(ContentUris.withAppendedId(uri, existingId))
                     .withExpectedCount(1)
-                    .withFailureAllowed(true);
+                    .withExceptionAllowed(true);
         }
     }
 
@@ -815,7 +813,7 @@
      * information isn't directly available.
      */
     static @NonNull Optional<Long> parseOptionalDateTaken(@NonNull ExifInterface exif,
-            @CurrentTimeMillisLong long lastModifiedTime) {
+            long lastModifiedTime) {
         final long originalTime = exif.getDateTimeOriginal();
         if (exif.hasAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)) {
             // We have known offset information, return it directly!
@@ -899,7 +897,7 @@
      * from the given {@link BasicFileAttributes}, except in the case of
      * read-only partitions, where {@link Build#TIME} is used instead.
      */
-    public static @CurrentTimeSecondsLong long lastModifiedTime(@NonNull File file,
+    public static long lastModifiedTime(@NonNull File file,
             @NonNull BasicFileAttributes attrs) {
         if (FileUtils.contains(Environment.getStorageDirectory(), file)) {
             return attrs.lastModifiedTime().toMillis() / 1000;
diff --git a/src/com/android/providers/media/util/BackgroundThread.java b/src/com/android/providers/media/util/BackgroundThread.java
index fd7083f..7b9cb54 100644
--- a/src/com/android/providers/media/util/BackgroundThread.java
+++ b/src/com/android/providers/media/util/BackgroundThread.java
@@ -17,7 +17,6 @@
 package com.android.providers.media.util;
 
 import android.os.Handler;
-import android.os.HandlerExecutor;
 import android.os.HandlerThread;
 
 import java.util.concurrent.Executor;
diff --git a/src/com/android/providers/media/util/DatabaseUtils.java b/src/com/android/providers/media/util/DatabaseUtils.java
new file mode 100644
index 0000000..ef921c2
--- /dev/null
+++ b/src/com/android/providers/media/util/DatabaseUtils.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.util;
+
+import android.database.Cursor;
+
+import androidx.annotation.Nullable;
+
+public class DatabaseUtils {
+    /**
+     * Bind the given selection with the given selection arguments.
+     * <p>
+     * Internally assumes that '?' is only ever used for arguments, and doesn't
+     * appear as a literal or escaped value.
+     * <p>
+     * This method is typically useful for trusted code that needs to cook up a
+     * fully-bound selection.
+     *
+     * @hide
+     */
+    public static @Nullable String bindSelection(@Nullable String selection,
+            @Nullable Object... selectionArgs) {
+        if (selection == null) return null;
+        // If no arguments provided, so we can't bind anything
+        if ((selectionArgs == null) || (selectionArgs.length == 0)) return selection;
+        // If no bindings requested, so we can shortcut
+        if (selection.indexOf('?') == -1) return selection;
+
+        // Track the chars immediately before and after each bind request, to
+        // decide if it needs additional whitespace added
+        char before = ' ';
+        char after = ' ';
+
+        int argIndex = 0;
+        final int len = selection.length();
+        final StringBuilder res = new StringBuilder(len);
+        for (int i = 0; i < len; ) {
+            char c = selection.charAt(i++);
+            if (c == '?') {
+                // Assume this bind request is guarded until we find a specific
+                // trailing character below
+                after = ' ';
+
+                // Sniff forward to see if the selection is requesting a
+                // specific argument index
+                int start = i;
+                for (; i < len; i++) {
+                    c = selection.charAt(i);
+                    if (c < '0' || c > '9') {
+                        after = c;
+                        break;
+                    }
+                }
+                if (start != i) {
+                    argIndex = Integer.parseInt(selection.substring(start, i)) - 1;
+                }
+
+                // Manually bind the argument into the selection, adding
+                // whitespace when needed for clarity
+                final Object arg = selectionArgs[argIndex++];
+                if (before != ' ' && before != '=') res.append(' ');
+                switch (DatabaseUtils.getTypeOfObject(arg)) {
+                    case Cursor.FIELD_TYPE_NULL:
+                        res.append("NULL");
+                        break;
+                    case Cursor.FIELD_TYPE_INTEGER:
+                        res.append(((Number) arg).longValue());
+                        break;
+                    case Cursor.FIELD_TYPE_FLOAT:
+                        res.append(((Number) arg).doubleValue());
+                        break;
+                    case Cursor.FIELD_TYPE_BLOB:
+                        throw new IllegalArgumentException("Blobs not supported");
+                    case Cursor.FIELD_TYPE_STRING:
+                    default:
+                        if (arg instanceof Boolean) {
+                            // Provide compatibility with legacy applications which may pass
+                            // Boolean values in bind args.
+                            res.append(((Boolean) arg).booleanValue() ? 1 : 0);
+                        } else {
+                            res.append('\'');
+                            res.append(arg.toString());
+                            res.append('\'');
+                        }
+                        break;
+                }
+                if (after != ' ') res.append(' ');
+            } else {
+                res.append(c);
+                before = c;
+            }
+        }
+        return res.toString();
+    }
+
+    /**
+     * Returns data type of the given object's value.
+     *<p>
+     * Returned values are
+     * <ul>
+     *   <li>{@link Cursor#FIELD_TYPE_NULL}</li>
+     *   <li>{@link Cursor#FIELD_TYPE_INTEGER}</li>
+     *   <li>{@link Cursor#FIELD_TYPE_FLOAT}</li>
+     *   <li>{@link Cursor#FIELD_TYPE_STRING}</li>
+     *   <li>{@link Cursor#FIELD_TYPE_BLOB}</li>
+     *</ul>
+     *</p>
+     *
+     * @param obj the object whose value type is to be returned
+     * @return object value type
+     * @hide
+     */
+    public static int getTypeOfObject(Object obj) {
+        if (obj == null) {
+            return Cursor.FIELD_TYPE_NULL;
+        } else if (obj instanceof byte[]) {
+            return Cursor.FIELD_TYPE_BLOB;
+        } else if (obj instanceof Float || obj instanceof Double) {
+            return Cursor.FIELD_TYPE_FLOAT;
+        } else if (obj instanceof Long || obj instanceof Integer
+                || obj instanceof Short || obj instanceof Byte) {
+            return Cursor.FIELD_TYPE_INTEGER;
+        } else {
+            return Cursor.FIELD_TYPE_STRING;
+        }
+    }
+}
diff --git a/src/com/android/providers/media/util/FileUtils.java b/src/com/android/providers/media/util/FileUtils.java
new file mode 100644
index 0000000..ad08f49
--- /dev/null
+++ b/src/com/android/providers/media/util/FileUtils.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.util;
+
+import static com.android.providers.media.MediaProvider.TAG;
+
+import android.annotation.TestApi;
+import android.content.ClipDescription;
+import android.text.TextUtils;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Objects;
+
+public class FileUtils {
+    public static void closeQuietly(@Nullable AutoCloseable closeable) {
+        android.os.FileUtils.closeQuietly(closeable);
+    }
+
+    public static long copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException {
+        return android.os.FileUtils.copy(in, out);
+    }
+
+    public static File buildPath(File base, String... segments) {
+        File cur = base;
+        for (String segment : segments) {
+            if (cur == null) {
+                cur = new File(segment);
+            } else {
+                cur = new File(cur, segment);
+            }
+        }
+        return cur;
+    }
+
+    /**
+     * Test if a file lives under the given directory, either as a direct child
+     * or a distant grandchild.
+     * <p>
+     * Both files <em>must</em> have been resolved using
+     * {@link File#getCanonicalFile()} to avoid symlink or path traversal
+     * attacks.
+     *
+     * @hide
+     */
+    public static boolean contains(File[] dirs, File file) {
+        for (File dir : dirs) {
+            if (contains(dir, file)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** {@hide} */
+    public static boolean contains(Collection<File> dirs, File file) {
+        for (File dir : dirs) {
+            if (contains(dir, file)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Test if a file lives under the given directory, either as a direct child
+     * or a distant grandchild.
+     * <p>
+     * Both files <em>must</em> have been resolved using
+     * {@link File#getCanonicalFile()} to avoid symlink or path traversal
+     * attacks.
+     *
+     * @hide
+     */
+    @TestApi
+    public static boolean contains(File dir, File file) {
+        if (dir == null || file == null) return false;
+        return contains(dir.getAbsolutePath(), file.getAbsolutePath());
+    }
+
+    /**
+     * Test if a file lives under the given directory, either as a direct child
+     * or a distant grandchild.
+     * <p>
+     * Both files <em>must</em> have been resolved using
+     * {@link File#getCanonicalFile()} to avoid symlink or path traversal
+     * attacks.
+     *
+     * @hide
+     */
+    public static boolean contains(String dirPath, String filePath) {
+        if (dirPath.equals(filePath)) {
+            return true;
+        }
+        if (!dirPath.endsWith("/")) {
+            dirPath += "/";
+        }
+        return filePath.startsWith(dirPath);
+    }
+
+    /** {@hide} */
+    public static boolean deleteContents(File dir) {
+        File[] files = dir.listFiles();
+        boolean success = true;
+        if (files != null) {
+            for (File file : files) {
+                if (file.isDirectory()) {
+                    success &= deleteContents(file);
+                }
+                if (!file.delete()) {
+                    Log.w(TAG, "Failed to delete " + file);
+                    success = false;
+                }
+            }
+        }
+        return success;
+    }
+
+    private static boolean isValidFatFilenameChar(char c) {
+        if ((0x00 <= c && c <= 0x1f)) {
+            return false;
+        }
+        switch (c) {
+            case '"':
+            case '*':
+            case '/':
+            case ':':
+            case '<':
+            case '>':
+            case '?':
+            case '\\':
+            case '|':
+            case 0x7F:
+                return false;
+            default:
+                return true;
+        }
+    }
+
+    /**
+     * Check if given filename is valid for a FAT filesystem.
+     *
+     * @hide
+     */
+    public static boolean isValidFatFilename(String name) {
+        return (name != null) && name.equals(buildValidFatFilename(name));
+    }
+
+    /**
+     * Mutate the given filename to make it valid for a FAT filesystem,
+     * replacing any invalid characters with "_".
+     *
+     * @hide
+     */
+    public static String buildValidFatFilename(String name) {
+        if (TextUtils.isEmpty(name) || ".".equals(name) || "..".equals(name)) {
+            return "(invalid)";
+        }
+        final StringBuilder res = new StringBuilder(name.length());
+        for (int i = 0; i < name.length(); i++) {
+            final char c = name.charAt(i);
+            if (isValidFatFilenameChar(c)) {
+                res.append(c);
+            } else {
+                res.append('_');
+            }
+        }
+        // Even though vfat allows 255 UCS-2 chars, we might eventually write to
+        // ext4 through a FUSE layer, so use that limit.
+        trimFilename(res, 255);
+        return res.toString();
+    }
+
+    /** {@hide} */
+    // @VisibleForTesting
+    public static String trimFilename(String str, int maxBytes) {
+        final StringBuilder res = new StringBuilder(str);
+        trimFilename(res, maxBytes);
+        return res.toString();
+    }
+
+    /** {@hide} */
+    private static void trimFilename(StringBuilder res, int maxBytes) {
+        byte[] raw = res.toString().getBytes(StandardCharsets.UTF_8);
+        if (raw.length > maxBytes) {
+            maxBytes -= 3;
+            while (raw.length > maxBytes) {
+                res.deleteCharAt(res.length() / 2);
+                raw = res.toString().getBytes(StandardCharsets.UTF_8);
+            }
+            res.insert(res.length() / 2, "...");
+        }
+    }
+
+    /** {@hide} */
+    private static File buildUniqueFileWithExtension(File parent, String name, String ext)
+            throws FileNotFoundException {
+        File file = buildFile(parent, name, ext);
+
+        // If conflicting file, try adding counter suffix
+        int n = 0;
+        while (file.exists()) {
+            if (n++ >= 32) {
+                throw new FileNotFoundException("Failed to create unique file");
+            }
+            file = buildFile(parent, name + " (" + n + ")", ext);
+        }
+
+        return file;
+    }
+
+    /**
+     * Generates a unique file name under the given parent directory. If the display name doesn't
+     * have an extension that matches the requested MIME type, the default extension for that MIME
+     * type is appended. If a file already exists, the name is appended with a numerical value to
+     * make it unique.
+     *
+     * For example, the display name 'example' with 'text/plain' MIME might produce
+     * 'example.txt' or 'example (1).txt', etc.
+     *
+     * @throws FileNotFoundException
+     * @hide
+     */
+    public static File buildUniqueFile(File parent, String mimeType, String displayName)
+            throws FileNotFoundException {
+        final String[] parts = splitFileName(mimeType, displayName);
+        return buildUniqueFileWithExtension(parent, parts[0], parts[1]);
+    }
+
+    /** {@hide} */
+    public static File buildNonUniqueFile(File parent, String mimeType, String displayName) {
+        final String[] parts = splitFileName(mimeType, displayName);
+        return buildFile(parent, parts[0], parts[1]);
+    }
+
+    /**
+     * Generates a unique file name under the given parent directory, keeping
+     * any extension intact.
+     *
+     * @hide
+     */
+    public static File buildUniqueFile(File parent, String displayName)
+            throws FileNotFoundException {
+        final String name;
+        final String ext;
+
+        // Extract requested extension from display name
+        final int lastDot = displayName.lastIndexOf('.');
+        if (lastDot >= 0) {
+            name = displayName.substring(0, lastDot);
+            ext = displayName.substring(lastDot + 1);
+        } else {
+            name = displayName;
+            ext = null;
+        }
+
+        return buildUniqueFileWithExtension(parent, name, ext);
+    }
+
+    /**
+     * Splits file name into base name and extension.
+     * If the display name doesn't have an extension that matches the requested MIME type, the
+     * extension is regarded as a part of filename and default extension for that MIME type is
+     * appended.
+     *
+     * @hide
+     */
+    public static String[] splitFileName(String mimeType, String displayName) {
+        String name;
+        String ext;
+
+        {
+            String mimeTypeFromExt;
+
+            // Extract requested extension from display name
+            final int lastDot = displayName.lastIndexOf('.');
+            if (lastDot >= 0) {
+                name = displayName.substring(0, lastDot);
+                ext = displayName.substring(lastDot + 1);
+                mimeTypeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
+                        ext.toLowerCase());
+            } else {
+                name = displayName;
+                ext = null;
+                mimeTypeFromExt = null;
+            }
+
+            if (mimeTypeFromExt == null) {
+                mimeTypeFromExt = ClipDescription.MIMETYPE_UNKNOWN;
+            }
+
+            final String extFromMimeType;
+            if (ClipDescription.MIMETYPE_UNKNOWN.equals(mimeType)) {
+                extFromMimeType = null;
+            } else {
+                extFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
+            }
+
+            if (Objects.equals(mimeType, mimeTypeFromExt) || Objects.equals(ext, extFromMimeType)) {
+                // Extension maps back to requested MIME type; allow it
+            } else {
+                // No match; insist that create file matches requested MIME
+                name = displayName;
+                ext = extFromMimeType;
+            }
+        }
+
+        if (ext == null) {
+            ext = "";
+        }
+
+        return new String[] { name, ext };
+    }
+
+    /** {@hide} */
+    private static File buildFile(File parent, String name, String ext) {
+        if (TextUtils.isEmpty(ext)) {
+            return new File(parent, name);
+        } else {
+            return new File(parent, name + "." + ext);
+        }
+    }
+}
diff --git a/src/com/android/providers/media/util/HandlerExecutor.java b/src/com/android/providers/media/util/HandlerExecutor.java
new file mode 100644
index 0000000..4d1b4ac
--- /dev/null
+++ b/src/com/android/providers/media/util/HandlerExecutor.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2017 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.util;
+
+import android.os.Handler;
+
+import androidx.annotation.NonNull;
+
+import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
+
+/**
+ * An adapter {@link Executor} that posts all executed tasks onto the given
+ * {@link Handler}.
+ *
+ * @hide
+ */
+public class HandlerExecutor implements Executor {
+    private final Handler mHandler;
+
+    public HandlerExecutor(@NonNull Handler handler) {
+        mHandler = Objects.requireNonNull(handler);
+    }
+
+    @Override
+    public void execute(Runnable command) {
+        if (!mHandler.post(command)) {
+            throw new RejectedExecutionException(mHandler + " is shutting down");
+        }
+    }
+}
diff --git a/tests/src/com/android/providers/media/IsoInterfaceTest.java b/tests/src/com/android/providers/media/IsoInterfaceTest.java
index 4565aa1..f95b4d4 100644
--- a/tests/src/com/android/providers/media/IsoInterfaceTest.java
+++ b/tests/src/com/android/providers/media/IsoInterfaceTest.java
@@ -19,12 +19,12 @@
 import static org.junit.Assert.assertEquals;
 
 import android.content.Context;
-import android.os.FileUtils;
 
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.providers.media.tests.R;
+import com.android.providers.media.util.FileUtils;
 import com.android.providers.media.util.IsoInterface;
 import com.android.providers.media.util.XmpInterface;
 
diff --git a/tests/src/com/android/providers/media/MediaProviderTest.java b/tests/src/com/android/providers/media/MediaProviderTest.java
index 11788ff..5d7e35a 100644
--- a/tests/src/com/android/providers/media/MediaProviderTest.java
+++ b/tests/src/com/android/providers/media/MediaProviderTest.java
@@ -186,34 +186,34 @@
     public void testBuildData_Simple() throws Exception {
         final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
         assertEndsWith("/Pictures/file.png",
-                buildFile(uri, null, null, "file", "image/png"));
+                buildFile(uri, null, "file", "image/png"));
         assertEndsWith("/Pictures/file.png",
-                buildFile(uri, null, null, "file.png", "image/png"));
+                buildFile(uri, null, "file.png", "image/png"));
         assertEndsWith("/Pictures/file.jpg.png",
-                buildFile(uri, null, null, "file.jpg", "image/png"));
+                buildFile(uri, null, "file.jpg", "image/png"));
     }
 
     @Test
     public void testBuildData_Primary() throws Exception {
         final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
         assertEndsWith("/DCIM/IMG_1024.JPG",
-                buildFile(uri, Environment.DIRECTORY_DCIM, null, "IMG_1024.JPG", "image/jpeg"));
+                buildFile(uri, Environment.DIRECTORY_DCIM, "IMG_1024.JPG", "image/jpeg"));
     }
 
     @Test
     public void testBuildData_Secondary() throws Exception {
         final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
         assertEndsWith("/Pictures/Screenshots/foo.png",
-                buildFile(uri, null, Environment.DIRECTORY_SCREENSHOTS, "foo.png", "image/png"));
+                buildFile(uri, "Pictures/Screenshots", "foo.png", "image/png"));
     }
 
     @Test
     public void testBuildData_InvalidNames() throws Exception {
         final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
         assertEndsWith("/Pictures/foo_bar.png",
-            buildFile(uri, null, null, "foo/bar", "image/png"));
+            buildFile(uri, null, "foo/bar", "image/png"));
         assertEndsWith("/Pictures/_.hidden.png",
-            buildFile(uri, null, null, ".hidden", "image/png"));
+            buildFile(uri, null, ".hidden", "image/png"));
     }
 
     @Test
@@ -224,19 +224,19 @@
             if (!type.startsWith("audio/")) {
                 assertThrows(IllegalArgumentException.class, () -> {
                     buildFile(MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
-                            null, null, "foo", type);
+                            null, "foo", type);
                 });
             }
             if (!type.startsWith("video/")) {
                 assertThrows(IllegalArgumentException.class, () -> {
                     buildFile(MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
-                            null, null, "foo", type);
+                            null, "foo", type);
                 });
             }
             if (!type.startsWith("image/")) {
                 assertThrows(IllegalArgumentException.class, () -> {
                     buildFile(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY),
-                            null, null, "foo", type);
+                            null, "foo", type);
                 });
             }
         }
@@ -246,7 +246,7 @@
     public void testBuildData_Charset() throws Exception {
         final Uri uri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
         assertEndsWith("/Pictures/foo__bar/bar__baz.png",
-                buildFile(uri, null, "foo\0\0bar", "bar::baz.png", "image/png"));
+                buildFile(uri, "Pictures/foo\0\0bar", "bar::baz.png", "image/png"));
     }
 
     @Test
@@ -566,7 +566,7 @@
             assertVolume(values, "0000-0000");
             assertBucket(values, "/storage/0000-0000/DCIM/Camera", "Camera");
             assertGroup(values, "IMG1024");
-            assertDirectories(values, "DCIM/Camera/", "DCIM", "Camera");
+            assertRelativePath(values, "DCIM/Camera/");
         }
     }
 
@@ -578,13 +578,13 @@
         assertVolume(values, "0000-0000");
         assertBucket(values, "/storage/0000-0000/DCIM/Camera", "Camera");
         assertGroup(values, null);
-        assertDirectories(values, "DCIM/Camera/", "DCIM", "Camera");
+        assertRelativePath(values, "DCIM/Camera/");
 
         values = computeDataValues("/storage/0000-0000/DCIM/Camera/.foo");
         assertVolume(values, "0000-0000");
         assertBucket(values, "/storage/0000-0000/DCIM/Camera", "Camera");
         assertGroup(values, null);
-        assertDirectories(values, "DCIM/Camera/", "DCIM", "Camera");
+        assertRelativePath(values, "DCIM/Camera/");
     }
 
     @Test
@@ -595,7 +595,7 @@
                 "IMG1024.JPG",
         }) {
             final ContentValues values = computeDataValues(data);
-            assertDirectories(values, null, null, null);
+            assertRelativePath(values, null);
         }
     }
 
@@ -611,25 +611,25 @@
             assertVolume(values, MediaStore.VOLUME_EXTERNAL_PRIMARY);
             assertBucket(values, top, null);
             assertGroup(values, "IMG1024");
-            assertDirectories(values, "/", null, null);
+            assertRelativePath(values, "/");
 
             values = computeDataValues(top + "/One/IMG1024.JPG");
             assertVolume(values, MediaStore.VOLUME_EXTERNAL_PRIMARY);
             assertBucket(values, top + "/One", "One");
             assertGroup(values, "IMG1024");
-            assertDirectories(values, "One/", "One", null);
+            assertRelativePath(values, "One/");
 
             values = computeDataValues(top + "/One/Two/IMG1024.JPG");
             assertVolume(values, MediaStore.VOLUME_EXTERNAL_PRIMARY);
             assertBucket(values, top + "/One/Two", "Two");
             assertGroup(values, "IMG1024");
-            assertDirectories(values, "One/Two/", "One", "Two");
+            assertRelativePath(values, "One/Two/");
 
             values = computeDataValues(top + "/One/Two/Three/IMG1024.JPG");
             assertVolume(values, MediaStore.VOLUME_EXTERNAL_PRIMARY);
             assertBucket(values, top + "/One/Two/Three", "Three");
             assertGroup(values, "IMG1024");
-            assertDirectories(values, "One/Two/Three/", "One", "Two");
+            assertRelativePath(values, "One/Two/Three/");
         }
     }
 
@@ -666,11 +666,8 @@
         assertEquals(volumeName, values.getAsString(ImageColumns.VOLUME_NAME));
     }
 
-    private static void assertDirectories(ContentValues values, String relativePath,
-            String primaryDir, String secondaryDir) {
+    private static void assertRelativePath(ContentValues values, String relativePath) {
         assertEquals(relativePath, values.get(ImageColumns.RELATIVE_PATH));
-        assertEquals(primaryDir, values.get(ImageColumns.PRIMARY_DIRECTORY));
-        assertEquals(secondaryDir, values.get(ImageColumns.SECONDARY_DIRECTORY));
     }
 
     private static boolean isGreylistMatch(String raw) {
@@ -682,14 +679,11 @@
         return false;
     }
 
-    private static String buildFile(Uri uri, String primaryDir, String secondaryDir,
-            String displayName, String mimeType) {
+    private static String buildFile(Uri uri, String relativePath, String displayName,
+            String mimeType) {
         final ContentValues values = new ContentValues();
-        if (primaryDir != null) {
-            values.put(MediaColumns.PRIMARY_DIRECTORY, primaryDir);
-        }
-        if (secondaryDir != null) {
-            values.put(MediaColumns.SECONDARY_DIRECTORY, secondaryDir);
+        if (relativePath != null) {
+            values.put(MediaColumns.RELATIVE_PATH, relativePath);
         }
         values.put(MediaColumns.DISPLAY_NAME, displayName);
         values.put(MediaColumns.MIME_TYPE, mimeType);
diff --git a/tests/src/com/android/providers/media/XmpInterfaceTest.java b/tests/src/com/android/providers/media/XmpInterfaceTest.java
index d13fce2..8f7c39f 100644
--- a/tests/src/com/android/providers/media/XmpInterfaceTest.java
+++ b/tests/src/com/android/providers/media/XmpInterfaceTest.java
@@ -23,7 +23,6 @@
 
 import android.content.Context;
 import android.media.ExifInterface;
-import android.os.FileUtils;
 import android.util.ArraySet;
 import android.util.Xml;
 
@@ -31,6 +30,7 @@
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.providers.media.tests.R;
+import com.android.providers.media.util.FileUtils;
 import com.android.providers.media.util.IsoInterface;
 import com.android.providers.media.util.XmpInterface;
 
diff --git a/tests/src/com/android/providers/media/scan/MediaScannerTest.java b/tests/src/com/android/providers/media/scan/MediaScannerTest.java
index d464212..64ef0c1 100644
--- a/tests/src/com/android/providers/media/scan/MediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/MediaScannerTest.java
@@ -27,7 +27,6 @@
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.Environment;
-import android.os.FileUtils;
 import android.os.SystemClock;
 import android.provider.BaseColumns;
 import android.provider.MediaStore;
@@ -37,8 +36,12 @@
 import android.test.mock.MockContentResolver;
 import android.util.Log;
 
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
 import com.android.providers.media.MediaProvider;
 import com.android.providers.media.tests.R;
+import com.android.providers.media.util.FileUtils;
 
 import org.junit.Before;
 import org.junit.Ignore;
@@ -52,9 +55,6 @@
 import java.io.OutputStream;
 import java.util.Arrays;
 
-import androidx.test.InstrumentationRegistry;
-import androidx.test.runner.AndroidJUnit4;
-
 @RunWith(AndroidJUnit4.class)
 public class MediaScannerTest {
     private static final String TAG = "MediaScannerTest";
@@ -186,7 +186,8 @@
         scanDirectory(scanner, scanDir, "Initial");
         scanDirectory(scanner, scanDir, "No-op");
 
-        FileUtils.deleteContentsAndDir(dir);
+        FileUtils.deleteContents(dir);
+        dir.delete();
         scanDirectory(scanner, scanDir, "Clean");
     }
 
diff --git a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
index 02f8381..f33a6d9 100644
--- a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
@@ -35,7 +35,6 @@
 import android.media.ExifInterface;
 import android.net.Uri;
 import android.os.Environment;
-import android.os.FileUtils;
 import android.os.ParcelFileDescriptor;
 import android.provider.MediaStore;
 import android.provider.MediaStore.Files.FileColumns;
@@ -47,6 +46,7 @@
 import com.android.providers.media.MediaProvider;
 import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
 import com.android.providers.media.tests.R;
+import com.android.providers.media.util.FileUtils;
 
 import org.junit.After;
 import org.junit.Assume;
diff --git a/tests/src/com/android/providers/media/util/FileUtilsTest.java b/tests/src/com/android/providers/media/util/FileUtilsTest.java
new file mode 100644
index 0000000..52d0e32
--- /dev/null
+++ b/tests/src/com/android/providers/media/util/FileUtilsTest.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2008 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.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+
+@RunWith(AndroidJUnit4.class)
+public class FileUtilsTest {
+    private File mTarget;
+
+    @Before
+    public void setUp() throws Exception {
+        mTarget = InstrumentationRegistry.getTargetContext().getCacheDir();
+        FileUtils.deleteContents(mTarget);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        FileUtils.deleteContents(mTarget);
+    }
+
+    @Test
+    public void testContains() throws Exception {
+        assertTrue(FileUtils.contains(new File("/"), new File("/moo.txt")));
+        assertTrue(FileUtils.contains(new File("/"), new File("/")));
+
+        assertTrue(FileUtils.contains(new File("/sdcard"), new File("/sdcard")));
+        assertTrue(FileUtils.contains(new File("/sdcard/"), new File("/sdcard/")));
+
+        assertTrue(FileUtils.contains(new File("/sdcard"), new File("/sdcard/moo.txt")));
+        assertTrue(FileUtils.contains(new File("/sdcard/"), new File("/sdcard/moo.txt")));
+
+        assertFalse(FileUtils.contains(new File("/sdcard"), new File("/moo.txt")));
+        assertFalse(FileUtils.contains(new File("/sdcard/"), new File("/moo.txt")));
+
+        assertFalse(FileUtils.contains(new File("/sdcard"), new File("/sdcard.txt")));
+        assertFalse(FileUtils.contains(new File("/sdcard/"), new File("/sdcard.txt")));
+    }
+
+    @Test
+    public void testValidFatFilename() throws Exception {
+        assertTrue(FileUtils.isValidFatFilename("a"));
+        assertTrue(FileUtils.isValidFatFilename("foo bar.baz"));
+        assertTrue(FileUtils.isValidFatFilename("foo.bar.baz"));
+        assertTrue(FileUtils.isValidFatFilename(".bar"));
+        assertTrue(FileUtils.isValidFatFilename("foo.bar"));
+        assertTrue(FileUtils.isValidFatFilename("foo bar"));
+        assertTrue(FileUtils.isValidFatFilename("foo+bar"));
+        assertTrue(FileUtils.isValidFatFilename("foo,bar"));
+
+        assertFalse(FileUtils.isValidFatFilename("foo*bar"));
+        assertFalse(FileUtils.isValidFatFilename("foo?bar"));
+        assertFalse(FileUtils.isValidFatFilename("foo<bar"));
+        assertFalse(FileUtils.isValidFatFilename(null));
+        assertFalse(FileUtils.isValidFatFilename("."));
+        assertFalse(FileUtils.isValidFatFilename("../foo"));
+        assertFalse(FileUtils.isValidFatFilename("/foo"));
+
+        assertEquals(".._foo", FileUtils.buildValidFatFilename("../foo"));
+        assertEquals("_foo", FileUtils.buildValidFatFilename("/foo"));
+        assertEquals(".foo", FileUtils.buildValidFatFilename(".foo"));
+        assertEquals("foo.bar", FileUtils.buildValidFatFilename("foo.bar"));
+        assertEquals("foo_bar__baz", FileUtils.buildValidFatFilename("foo?bar**baz"));
+    }
+
+    @Test
+    public void testTrimFilename() throws Exception {
+        assertEquals("short.txt", FileUtils.trimFilename("short.txt", 16));
+        assertEquals("extrem...eme.txt", FileUtils.trimFilename("extremelylongfilename.txt", 16));
+
+        final String unicode = "a\u03C0\u03C0\u03C0\u03C0z";
+        assertEquals("a\u03C0\u03C0\u03C0\u03C0z", FileUtils.trimFilename(unicode, 10));
+        assertEquals("a\u03C0...\u03C0z", FileUtils.trimFilename(unicode, 9));
+        assertEquals("a...\u03C0z", FileUtils.trimFilename(unicode, 8));
+        assertEquals("a...\u03C0z", FileUtils.trimFilename(unicode, 7));
+        assertEquals("a...z", FileUtils.trimFilename(unicode, 6));
+    }
+
+    @Test
+    public void testBuildUniqueFile_normal() throws Exception {
+        assertNameEquals("test.jpg", FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test"));
+        assertNameEquals("test.jpg", FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test.jpg"));
+        assertNameEquals("test.jpeg", FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test.jpeg"));
+        assertNameEquals("TEst.JPeg", FileUtils.buildUniqueFile(mTarget, "image/jpeg", "TEst.JPeg"));
+        assertNameEquals("test.png.jpg",
+                FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test.png.jpg"));
+        assertNameEquals("test.png.jpg",
+                FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test.png"));
+
+        assertNameEquals("test.flac", FileUtils.buildUniqueFile(mTarget, "audio/flac", "test"));
+        assertNameEquals("test.flac", FileUtils.buildUniqueFile(mTarget, "audio/flac", "test.flac"));
+        assertNameEquals("test.flac",
+                FileUtils.buildUniqueFile(mTarget, "application/x-flac", "test"));
+        assertNameEquals("test.flac",
+                FileUtils.buildUniqueFile(mTarget, "application/x-flac", "test.flac"));
+    }
+
+    @Test
+    public void testBuildUniqueFile_unknown() throws Exception {
+        assertNameEquals("test",
+                FileUtils.buildUniqueFile(mTarget, "application/octet-stream", "test"));
+        assertNameEquals("test.jpg",
+                FileUtils.buildUniqueFile(mTarget, "application/octet-stream", "test.jpg"));
+        assertNameEquals(".test",
+                FileUtils.buildUniqueFile(mTarget, "application/octet-stream", ".test"));
+
+        assertNameEquals("test", FileUtils.buildUniqueFile(mTarget, "lolz/lolz", "test"));
+        assertNameEquals("test.lolz", FileUtils.buildUniqueFile(mTarget, "lolz/lolz", "test.lolz"));
+    }
+
+    @Test
+    public void testBuildUniqueFile_increment() throws Exception {
+        assertNameEquals("test.jpg",
+                FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test.jpg"));
+        new File(mTarget, "test.jpg").createNewFile();
+        assertNameEquals("test (1).jpg",
+                FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test.jpg"));
+        new File(mTarget, "test (1).jpg").createNewFile();
+        assertNameEquals("test (2).jpg",
+                FileUtils.buildUniqueFile(mTarget, "image/jpeg", "test.jpg"));
+    }
+
+    @Test
+    public void testBuildUniqueFile_mimeless() throws Exception {
+        assertNameEquals("test.jpg", FileUtils.buildUniqueFile(mTarget, "test.jpg"));
+        new File(mTarget, "test.jpg").createNewFile();
+        assertNameEquals("test (1).jpg", FileUtils.buildUniqueFile(mTarget, "test.jpg"));
+
+        assertNameEquals("test", FileUtils.buildUniqueFile(mTarget, "test"));
+        new File(mTarget, "test").createNewFile();
+        assertNameEquals("test (1)", FileUtils.buildUniqueFile(mTarget, "test"));
+
+        assertNameEquals("test.foo.bar", FileUtils.buildUniqueFile(mTarget, "test.foo.bar"));
+        new File(mTarget, "test.foo.bar").createNewFile();
+        assertNameEquals("test.foo (1).bar", FileUtils.buildUniqueFile(mTarget, "test.foo.bar"));
+    }
+
+    private static void assertNameEquals(String expected, File actual) {
+        assertEquals(expected, actual.getName());
+    }
+}