Merge "Fix LegacyProviderMigrationTest" into rvc-dev
diff --git a/jni/FuseDaemon.cpp b/jni/FuseDaemon.cpp
index 208a074..a014489 100644
--- a/jni/FuseDaemon.cpp
+++ b/jni/FuseDaemon.cpp
@@ -19,6 +19,7 @@
 #include "FuseDaemon.h"
 
 #include <android-base/logging.h>
+#include <android-base/properties.h>
 #include <android/log.h>
 #include <android/trace.h>
 #include <ctype.h>
@@ -75,7 +76,7 @@
 
 // logging macros to avoid duplication.
 #define TRACE_NODE(__node) \
-    LOG(DEBUG) << __FUNCTION__ << " : " << #__node << " = [" << safe_name(__node) << "] "
+    LOG(VERBOSE) << __FUNCTION__ << " : " << #__node << " = [" << get_name(__node) << "] "
 
 #define ATRACE_NAME(name) ScopedTrace ___tracer(name)
 #define ATRACE_CALL() ATRACE_NAME(__FUNCTION__)
@@ -91,24 +92,21 @@
     }
 };
 
+const bool IS_OS_DEBUGABLE = android::base::GetIntProperty("ro.debuggable", 0);
+
 #define FUSE_UNKNOWN_INO 0xffffffff
 
 constexpr size_t MAX_READ_SIZE = 128 * 1024;
 // Stolen from: UserHandle#getUserId
 constexpr int PER_USER_RANGE = 100000;
-// Cache inode attributes for a 'short' time so that performance is decent and last modified time
-// stamps are not too stale
-constexpr double DEFAULT_ATTR_TIMEOUT_SECONDS = 10;
-// Ensure the VFS does not cache dentries, if it caches, the following scenario could occur:
-// 1. Process A has access to file A and does a lookup
-// 2. Process B does not have access to file A and does a lookup
-// (2) will succeed because the lookup request will not be sent from kernel to the FUSE daemon
-// and the kernel will respond from cache. Even if this by itself is not a security risk
-// because subsequent FUSE requests will fail if B does not have access to the resource.
-// It does cause indeterministic behavior because whether (2) succeeds or not depends on if
-// (1) occurred.
-// We prevent this caching by setting the entry_timeout value to 0.
-constexpr double DEFAULT_ENTRY_TIMEOUT_SECONDS = 0;
+// Cache inode attributes forever to improve performance
+// Whenver attributes could have changed on the lower filesystem outside the FUSE driver, we call
+// fuse_invalidate_entry_cache
+constexpr double DEFAULT_ATTR_TIMEOUT_SECONDS = std::numeric_limits<double>::max();
+// Cache dentries forever to improve performance
+// Whenver attributes could have changed on the lower filesystem outside the FUSE driver, we call
+// fuse_invalidate_entry_cache
+constexpr double DEFAULT_ENTRY_TIMEOUT_SECONDS = std::numeric_limits<double>::max();
 
 /*
  * In order to avoid double caching with fuse, call fadvise on the file handles
@@ -287,10 +285,19 @@
     /* const */ char* zero_addr;
 
     FAdviser fadviser;
+
+    std::atomic_bool* active;
 };
 
-static inline string safe_name(node* n) {
-    return n ? n->BuildSafePath() : "?";
+static inline string get_name(node* n) {
+    if (n) {
+        std::string name("node_path: " + n->BuildSafePath());
+        if (IS_OS_DEBUGABLE) {
+            name += " real_path: " + n->BuildPath();
+        }
+        return name;
+    }
+    return "?";
 }
 
 static inline __u64 ptr_to_id(void* ptr) {
@@ -356,7 +363,7 @@
 static double get_attr_timeout(const string& path, uid_t uid, struct fuse* fuse, node* parent) {
     if (fuse->IsRoot(parent) || is_android_path(path, fuse->path, uid)) {
         // The /0 and /0/Android attrs can be always cached, as they never change
-        return DBL_MAX;
+        return std::numeric_limits<double>::max();
     } else {
         return DEFAULT_ATTR_TIMEOUT_SECONDS;
     }
@@ -365,7 +372,7 @@
 static double get_entry_timeout(const string& path, uid_t uid, struct fuse* fuse, node* parent) {
     if (fuse->IsRoot(parent) || is_android_path(path, fuse->path, uid)) {
         // The /0 and /0/Android dentries can be always cached, as they are visible to all apps
-        return DBL_MAX;
+        return std::numeric_limits<double>::max();
     } else {
         return DEFAULT_ENTRY_TIMEOUT_SECONDS;
     }
@@ -423,6 +430,9 @@
                      FUSE_CAP_EXPORT_SUPPORT | FUSE_CAP_FLOCK_LOCKS);
     conn->want |= conn->capable & mask;
     conn->max_read = MAX_READ_SIZE;
+
+    struct fuse* fuse = reinterpret_cast<struct fuse*>(userdata);
+    fuse->active->store(true, std::memory_order_release);
 }
 
 static void pf_destroy(void* userdata) {
@@ -1491,6 +1501,7 @@
 }
 
 void FuseDaemon::InvalidateFuseDentryCache(const std::string& path) {
+    LOG(VERBOSE) << "Invalidating FUSE dentry cache";
     if (active.load(std::memory_order_acquire)) {
         string name;
         fuse_ino_t parent;
@@ -1516,6 +1527,10 @@
 FuseDaemon::FuseDaemon(JNIEnv* env, jobject mediaProvider) : mp(env, mediaProvider),
                                                              active(false), fuse(nullptr) {}
 
+bool FuseDaemon::IsStarted() const {
+    return active.load(std::memory_order_acquire);
+}
+
 void FuseDaemon::Start(const int fd, const std::string& path) {
     struct fuse_args args;
     struct fuse_cmdline_opts opts;
@@ -1566,6 +1581,7 @@
         return;
     }
     fuse_default.se = se;
+    fuse_default.active = &active;
     se->fd = fd;
     se->mountpoint = strdup(path.c_str());
 
@@ -1573,8 +1589,8 @@
     // fuse_session_loop(se);
     // Multi-threaded
     LOG(INFO) << "Starting fuse...";
-    active.store(true, std::memory_order_release);
     fuse_session_loop_mt(se, &config);
+    fuse->active->store(false, std::memory_order_release);
     LOG(INFO) << "Ending fuse...";
 
     if (munmap(fuse_default.zero_addr, MAX_READ_SIZE)) {
@@ -1583,7 +1599,6 @@
 
     fuse_opt_free_args(&args);
     fuse_session_destroy(se);
-    active.store(false, std::memory_order_relaxed);
     LOG(INFO) << "Ended fuse";
     return;
 }
diff --git a/jni/FuseDaemon.h b/jni/FuseDaemon.h
index 1d25636..d9925d4 100644
--- a/jni/FuseDaemon.h
+++ b/jni/FuseDaemon.h
@@ -38,6 +38,11 @@
     void Start(const int fd, const std::string& path);
 
     /**
+     * Checks if the FUSE daemon is started.
+     */
+    bool IsStarted() const;
+
+    /**
      * Check if file should be opened with FUSE
      */
     bool ShouldOpenWithFuse(int fd, bool for_read, const std::string& path);
diff --git a/jni/com_android_providers_media_FuseDaemon.cpp b/jni/com_android_providers_media_FuseDaemon.cpp
index bede40a..fe4848f 100644
--- a/jni/com_android_providers_media_FuseDaemon.cpp
+++ b/jni/com_android_providers_media_FuseDaemon.cpp
@@ -50,6 +50,13 @@
     daemon->Start(fd, utf_chars_path.c_str());
 }
 
+bool com_android_providers_media_FuseDaemon_is_started(JNIEnv* env, jobject self,
+                                                       jlong java_daemon) {
+    LOG(DEBUG) << "Checking if FUSE daemon started...";
+    const fuse::FuseDaemon* daemon = reinterpret_cast<fuse::FuseDaemon*>(java_daemon);
+    return daemon->IsStarted();
+}
+
 void com_android_providers_media_FuseDaemon_delete(JNIEnv* env, jobject self, jlong java_daemon) {
     LOG(DEBUG) << "Destroying the FUSE daemon...";
     fuse::FuseDaemon* const daemon = reinterpret_cast<fuse::FuseDaemon*>(java_daemon);
@@ -85,6 +92,7 @@
             return;
         }
 
+        CHECK_EQ(pthread_getspecific(fuse::MediaProviderWrapper::gJniEnvKey), nullptr);
         daemon->InvalidateFuseDentryCache(utf_chars_path.c_str());
     }
     // TODO(b/145741152): Throw exception
@@ -106,6 +114,8 @@
          reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_should_open_with_fuse)},
         {"native_is_fuse_thread", "()Z",
          reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_is_fuse_thread)},
+        {"native_is_started", "(J)Z",
+         reinterpret_cast<void*>(com_android_providers_media_FuseDaemon_is_started)},
         {"native_invalidate_fuse_dentry_cache", "(JLjava/lang/String;)V",
          reinterpret_cast<void*>(
                  com_android_providers_media_FuseDaemon_invalidate_fuse_dentry_cache)}};
diff --git a/src/com/android/providers/media/LocalCallingIdentity.java b/src/com/android/providers/media/LocalCallingIdentity.java
index 1632b47..ef6c27f 100644
--- a/src/com/android/providers/media/LocalCallingIdentity.java
+++ b/src/com/android/providers/media/LocalCallingIdentity.java
@@ -57,15 +57,15 @@
     public final int pid;
     public final int uid;
     public final String packageNameUnchecked;
-    public @Nullable String featureId;
+    public @Nullable String attributionTag;
 
     private LocalCallingIdentity(Context context, int pid, int uid, String packageNameUnchecked,
-            @Nullable String featureId) {
+            @Nullable String attributionTag) {
         this.context = context;
         this.pid = pid;
         this.uid = uid;
         this.packageNameUnchecked = packageNameUnchecked;
-        this.featureId = featureId;
+        this.attributionTag = attributionTag;
     }
 
     /**
@@ -109,12 +109,12 @@
         if (callingPackage == null) {
             callingPackage = context.getOpPackageName();
         }
-        String callingFeatureId = provider.getCallingFeatureId();
-        if (callingFeatureId == null) {
-            callingFeatureId = context.getFeatureId();
+        String callingAttributionTag = provider.getCallingAttributionTag();
+        if (callingAttributionTag == null) {
+            callingAttributionTag = context.getAttributionTag();
         }
         return new LocalCallingIdentity(context, Binder.getCallingPid(), Binder.getCallingUid(),
-                callingPackage, callingFeatureId);
+                callingPackage, callingAttributionTag);
     }
 
     public static LocalCallingIdentity fromExternal(Context context, int uid) {
@@ -123,6 +123,8 @@
             throw new IllegalArgumentException("UID " + uid + " has no associated package");
         }
         LocalCallingIdentity ident =  fromExternal(context, uid, sharedPackageNames[0], null);
+        ident.sharedPackageNames = sharedPackageNames;
+        ident.sharedPackageNamesResolved = true;
         if (uid == Process.SHELL_UID) {
             // This is useful for debugging/testing/development
             if (SystemProperties.getBoolean("persist.sys.fuse.shell.redaction-needed", false)) {
@@ -134,8 +136,8 @@
     }
 
     public static LocalCallingIdentity fromExternal(Context context, int uid, String packageName,
-            @Nullable String featureId) {
-        return new LocalCallingIdentity(context, -1, uid, packageName, featureId);
+            @Nullable String attributionTag) {
+        return new LocalCallingIdentity(context, -1, uid, packageName, attributionTag);
     }
 
     public static LocalCallingIdentity fromSelf(Context context) {
@@ -144,11 +146,11 @@
                 android.os.Process.myPid(),
                 android.os.Process.myUid(),
                 context.getOpPackageName(),
-                context.getFeatureId());
+                context.getAttributionTag());
 
         ident.packageName = ident.packageNameUnchecked;
         ident.packageNameResolved = true;
-        // Use ident.featureId from context, hence no change
+        // Use ident.attributionTag from context, hence no change
         ident.targetSdkVersion = Build.VERSION_CODES.CUR_DEVELOPMENT;
         ident.targetSdkVersionResolved = true;
         ident.hasPermission = ~(PERMISSION_IS_LEGACY_GRANTED | PERMISSION_IS_LEGACY_WRITE
@@ -335,7 +337,7 @@
 
         if (context.checkPermission(ACCESS_MEDIA_LOCATION, pid, uid) == PERMISSION_DENIED
                 || context.getSystemService(AppOpsManager.class).noteProxyOpNoThrow(
-                permissionToOp(ACCESS_MEDIA_LOCATION), getPackageName(), uid, featureId, null)
+                permissionToOp(ACCESS_MEDIA_LOCATION), getPackageName(), uid, attributionTag, null)
                 != MODE_ALLOWED) {
             return true;
         }
diff --git a/src/com/android/providers/media/MediaDocumentsProvider.java b/src/com/android/providers/media/MediaDocumentsProvider.java
index d7caf18..d90a5e0 100644
--- a/src/com/android/providers/media/MediaDocumentsProvider.java
+++ b/src/com/android/providers/media/MediaDocumentsProvider.java
@@ -142,10 +142,6 @@
 
     public static final String METADATA_KEY_AUDIO = "android.media.metadata.audio";
     public static final String METADATA_KEY_VIDEO = "android.media.metadata.video";
-    // Video lat/long are just that. Lat/long. Unlike EXIF where the values are
-    // in fact some funky string encoding. So we add our own contstant to convey coords.
-    public static final String METADATA_VIDEO_LATITUDE = "android.media.metadata.video:latitude";
-    public static final String METADATA_VIDEO_LONGITUTE = "android.media.metadata.video:longitude";
 
     /*
      * A mapping between media columns and metadata tag names. These keys of the
@@ -164,14 +160,10 @@
         IMAGE_COLUMN_MAP.put(ImageColumns.WIDTH, ExifInterface.TAG_IMAGE_WIDTH);
         IMAGE_COLUMN_MAP.put(ImageColumns.HEIGHT, ExifInterface.TAG_IMAGE_LENGTH);
         IMAGE_COLUMN_MAP.put(ImageColumns.DATE_TAKEN, ExifInterface.TAG_DATETIME);
-        IMAGE_COLUMN_MAP.put(ImageColumns.LATITUDE, ExifInterface.TAG_GPS_LATITUDE);
-        IMAGE_COLUMN_MAP.put(ImageColumns.LONGITUDE, ExifInterface.TAG_GPS_LONGITUDE);
 
         VIDEO_COLUMN_MAP.put(VideoColumns.DURATION, MediaMetadata.METADATA_KEY_DURATION);
         VIDEO_COLUMN_MAP.put(VideoColumns.HEIGHT, ExifInterface.TAG_IMAGE_LENGTH);
         VIDEO_COLUMN_MAP.put(VideoColumns.WIDTH, ExifInterface.TAG_IMAGE_WIDTH);
-        VIDEO_COLUMN_MAP.put(VideoColumns.LATITUDE, METADATA_VIDEO_LATITUDE);
-        VIDEO_COLUMN_MAP.put(VideoColumns.LONGITUDE, METADATA_VIDEO_LONGITUTE);
         VIDEO_COLUMN_MAP.put(VideoColumns.DATE_TAKEN, MediaMetadata.METADATA_KEY_DATE);
 
         AUDIO_COLUMN_MAP.put(AudioColumns.ARTIST, MediaMetadata.METADATA_KEY_ARTIST);
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 5a3c14a..67b7d86 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -16,6 +16,8 @@
 
 package com.android.providers.media;
 
+import static android.Manifest.permission.ACCESS_MEDIA_LOCATION;
+import static android.app.AppOpsManager.permissionToOp;
 import static android.app.PendingIntent.FLAG_CANCEL_CURRENT;
 import static android.app.PendingIntent.FLAG_IMMUTABLE;
 import static android.app.PendingIntent.FLAG_ONE_SHOT;
@@ -69,6 +71,7 @@
 
 import android.app.AppOpsManager;
 import android.app.AppOpsManager.OnOpActiveChangedListener;
+import android.app.AppOpsManager.OnOpChangedListener;
 import android.app.DownloadManager;
 import android.app.PendingIntent;
 import android.app.RecoverableSecurityException;
@@ -360,6 +363,29 @@
     };
 
     /**
+     * Map from UID to cached {@link LocalCallingIdentity}. Values are only
+     * maintained in this map until there's any change in the appops needed or packages
+     * used in the {@link LocalCallingIdentity}.
+     */
+    @GuardedBy("mCachedCallingIdentityForFuse")
+    private final SparseArray<LocalCallingIdentity> mCachedCallingIdentityForFuse =
+            new SparseArray<>();
+
+    private OnOpChangedListener mModeListener =
+            (op, packageName) -> invalidateLocalCallingIdentityCache(packageName, "op " + op);
+
+    private LocalCallingIdentity getCachedCallingIdentityForFuse(int uid) {
+        synchronized (mCachedCallingIdentityForFuse) {
+            LocalCallingIdentity ident = mCachedCallingIdentityForFuse.get(uid);
+            if (ident == null) {
+               ident = LocalCallingIdentity.fromExternal(getContext(), uid);
+               mCachedCallingIdentityForFuse.put(uid, ident);
+            }
+            return ident;
+        }
+    }
+
+    /**
      * Calling identity state about on the current thread. Populated on demand,
      * and invalidated by {@link #onCallingPackageChanged()} when each remote
      * call is finished.
@@ -445,6 +471,36 @@
         }
     };
 
+    private BroadcastReceiver mPackageReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            switch (intent.getAction()) {
+                case Intent.ACTION_PACKAGE_REMOVED:
+                case Intent.ACTION_PACKAGE_ADDED:
+                    Uri uri = intent.getData();
+                    String pkg = uri != null ? uri.getSchemeSpecificPart() : null;
+                    if (pkg != null) {
+                        invalidateLocalCallingIdentityCache(pkg, "package " + intent.getAction());
+                    } else {
+                        Log.w(TAG, "Failed to retrieve package from intent: " + intent.getAction());
+                    }
+                    break;
+            }
+        }
+    };
+
+    private void invalidateLocalCallingIdentityCache(String packageName, String reason) {
+        synchronized (mCachedCallingIdentityForFuse) {
+            try {
+                Log.i(TAG, "Invalidating LocalCallingIdentity cache for package " + packageName
+                        + ". Reason: " + reason);
+                mCachedCallingIdentityForFuse.remove(
+                        getContext().getPackageManager().getPackageUid(packageName, 0));
+            } catch (NameNotFoundException ignored) {
+            }
+        }
+    }
+
     private final void updateQuotaTypeForUri(@NonNull Uri uri, int mediaType) {
         Trace.beginSection("updateQuotaTypeForUri");
         try {
@@ -733,6 +789,13 @@
         filter.addAction(Intent.ACTION_MEDIA_BAD_REMOVAL);
         context.registerReceiver(mMediaReceiver, filter);
 
+        final IntentFilter packageFilter = new IntentFilter();
+        packageFilter.setPriority(10);
+        filter.addDataScheme("package");
+        packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
+        packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+        context.registerReceiver(mPackageReceiver, packageFilter);
+
         // Watch for invalidation of cached volumes
         mStorageManager.registerStorageVolumeCallback(context.getMainExecutor(),
                 new StorageVolumeCallback() {
@@ -753,6 +816,24 @@
                 AppOpsManager.OPSTR_CAMERA
         }, context.getMainExecutor(), mActiveListener);
 
+
+        mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_EXTERNAL_STORAGE,
+                null /* all packages */, mModeListener);
+        mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_EXTERNAL_STORAGE,
+                null /* all packages */, mModeListener);
+        mAppOpsManager.startWatchingMode(permissionToOp(ACCESS_MEDIA_LOCATION),
+                null /* all packages */, mModeListener);
+        // Legacy apps
+        mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_LEGACY_STORAGE,
+                null /* all packages */, mModeListener);
+        // File managers
+        mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_MANAGE_EXTERNAL_STORAGE,
+                null /* all packages */, mModeListener);
+        // Default gallery changes
+        mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES,
+                null /* all packages */, mModeListener);
+        mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_MEDIA_VIDEO,
+                null /* all packages */, mModeListener);
         return true;
     }
 
@@ -923,8 +1004,7 @@
      */
     @Keep
     public void scanFileForFuse(String file, int uid) {
-        final String callingPackage =
-                LocalCallingIdentity.fromExternal(getContext(), uid).getPackageName();
+        final String callingPackage = getCachedCallingIdentityForFuse(uid).getPackageName();
         scanFile(new File(file), REASON_DEMAND, callingPackage);
     }
 
@@ -1150,8 +1230,9 @@
      */
     @Keep
     public String[] getFilesInDirectoryForFuse(String path, int uid) {
-        final LocalCallingIdentity token = clearLocalCallingIdentity(
-                LocalCallingIdentity.fromExternal(getContext(), uid));
+        final LocalCallingIdentity token =
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
         try {
             final String appSpecificDir = extractPathOwnerPackageName(path);
             // Apps are allowed to list files only in their own external directory.
@@ -1583,8 +1664,9 @@
     @Keep
     public int renameForFuse(String oldPath, String newPath, int uid) {
         final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed. ";
-        final LocalCallingIdentity token = clearLocalCallingIdentity(
-                LocalCallingIdentity.fromExternal(getContext(), uid));
+        final LocalCallingIdentity token =
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
         try {
             final String oldPathPackageName = extractPathOwnerPackageName(oldPath);
             final String newPathPackageName = extractPathOwnerPackageName(newPath);
@@ -1672,8 +1754,9 @@
     @Override
     public int checkUriPermission(@NonNull Uri uri, int uid,
             /* @Intent.AccessUriMode */ int modeFlags) {
-        final LocalCallingIdentity token = clearLocalCallingIdentity(
-                LocalCallingIdentity.fromExternal(getContext(), uid));
+        final LocalCallingIdentity token =
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
         try {
             final boolean allowHidden = isCallingPackageAllowedHidden();
             final int table = matchUri(uri, allowHidden);
@@ -4577,6 +4660,12 @@
                 Log.d(TAG, "Moving " + beforePath + " to " + afterPath);
                 try {
                     Os.rename(beforePath, afterPath);
+                    if (!FuseDaemon.native_is_fuse_thread()) {
+                        // If we are on a FUSE thread, we don't need to invalidate,
+                        // (and *must* not, otherwise we'd crash) because the rename is already
+                        // reflected in the lower filesystem
+                        invalidateFuseDentry(beforePath);
+                    }
                 } catch (ErrnoException e) {
                     throw new IllegalStateException(e);
                 }
@@ -5029,6 +5118,15 @@
         return ExternalStorageServiceImpl.getFuseDaemon(volume.getId());
     }
 
+    private void invalidateFuseDentry(String path) {
+        FuseDaemon daemon = getFuseDaemonForFile(new File(path));
+        if (daemon != null) {
+            daemon.invalidateFuseDentryCache(path);
+        } else {
+            Log.w(TAG, "Failed to invalidate FUSE dentry. Daemon unavailable for path " + path);
+        }
+    }
+
     /**
      * Replacement for {@link #openFileHelper(Uri, String)} which enforces any
      * permissions applicable to the path before returning.
@@ -5180,6 +5278,12 @@
             final File file = new File(path);
             checkAccess(uri, extras, file, true);
             file.delete();
+            if (!FuseDaemon.native_is_fuse_thread()) {
+                // If we are on a FUSE thread, we don't need to invalidate,
+                // (and *must* not, otherwise we'd crash) because the delete is already
+                // reflected in the lower filesystem
+                invalidateFuseDentry(path);
+            }
         } catch (Exception e) {
             Log.e(TAG, "Couldn't delete " + path, e);
         }
@@ -5356,8 +5460,8 @@
             return getRedactionRanges(file).redactionRanges;
         }
 
-        LocalCallingIdentity token =
-                clearLocalCallingIdentity(LocalCallingIdentity.fromExternal(getContext(), uid));
+        final LocalCallingIdentity token =
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
 
         long[] res = new long[0];
         try {
@@ -5486,7 +5590,9 @@
     @Keep
     public int isOpenAllowedForFuse(String path, int uid, boolean forWrite) {
         final LocalCallingIdentity token =
-                clearLocalCallingIdentity(LocalCallingIdentity.fromExternal(getContext(), uid));
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
+
         try {
             // Returns null if the path doesn't correspond to an app specific directory
             final String appSpecificDir = extractPathOwnerPackageName(path);
@@ -5671,7 +5777,8 @@
     @Keep
     public int insertFileIfNecessaryForFuse(@NonNull String path, int uid) {
         final LocalCallingIdentity token =
-                clearLocalCallingIdentity(LocalCallingIdentity.fromExternal(getContext(), uid));
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
         try {
             // Returns null if the path doesn't correspond to an app specific directory
             final String appSpecificDir = extractPathOwnerPackageName(path);
@@ -5753,7 +5860,8 @@
     @Keep
     public int deleteFileForFuse(@NonNull String path, int uid) throws IOException {
         final LocalCallingIdentity token =
-                clearLocalCallingIdentity(LocalCallingIdentity.fromExternal(getContext(), uid));
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
         try {
             // Check if app is deleting a file under an app specific directory
             final String appSpecificDir = extractPathOwnerPackageName(path);
@@ -5828,7 +5936,8 @@
     public int isDirectoryCreationOrDeletionAllowedForFuse(
             @NonNull String path, int uid, boolean forCreate) {
         final LocalCallingIdentity token =
-                clearLocalCallingIdentity(LocalCallingIdentity.fromExternal(getContext(), uid));
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
         try {
             // Returns null if the path doesn't correspond to an app specific directory
             final String appSpecificDir = extractPathOwnerPackageName(path);
@@ -5887,7 +5996,8 @@
     @Keep
     public int isOpendirAllowedForFuse(@NonNull String path, int uid) {
         final LocalCallingIdentity token =
-                clearLocalCallingIdentity(LocalCallingIdentity.fromExternal(getContext(), uid));
+                clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
+
         try {
             // Returns null if the path doesn't correspond to an app specific directory
             final String appSpecificDir = extractPathOwnerPackageName(path);
diff --git a/src/com/android/providers/media/fuse/FuseDaemon.java b/src/com/android/providers/media/fuse/FuseDaemon.java
index c3f0e5f..95a160b 100644
--- a/src/com/android/providers/media/fuse/FuseDaemon.java
+++ b/src/com/android/providers/media/fuse/FuseDaemon.java
@@ -16,11 +16,13 @@
 
 package com.android.providers.media.fuse;
 
+import android.os.ConditionVariable;
 import android.os.ParcelFileDescriptor;
 import android.util.Log;
 
 import androidx.annotation.NonNull;
 
+import com.android.internal.annotations.GuardedBy;
 import com.android.providers.media.MediaProvider;
 
 import java.util.Objects;
@@ -30,11 +32,15 @@
  */
 public final class FuseDaemon extends Thread {
     public static final String TAG = "FuseDaemonThread";
+    private static final int POLL_INTERVAL_MS = 1000;
+    private static final int POLL_COUNT = 5;
 
+    private final Object mLock = new Object();
     private final MediaProvider mMediaProvider;
     private final int mFuseDeviceFd;
     private final String mPath;
     private final ExternalStorageServiceImpl mService;
+    @GuardedBy("mLock")
     private long mPtr;
 
     public FuseDaemon(@NonNull MediaProvider mediaProvider,
@@ -50,17 +56,18 @@
     /** Starts a FUSE session. Does not return until the lower filesystem is unmounted. */
     @Override
     public void run() {
-        mPtr = native_new(mMediaProvider);
-        if (mPtr == 0) {
-            return;
+        synchronized (mLock) {
+            mPtr = native_new(mMediaProvider);
+            if (mPtr == 0) {
+                throw new IllegalStateException("Unable to create native FUSE daemon");
+            }
         }
 
         Log.i(TAG, "Starting thread for " + getName() + " ...");
         native_start(mPtr, mFuseDeviceFd, mPath); // Blocks
         Log.i(TAG, "Exiting thread for " + getName() + " ...");
 
-        // Cleanup
-        if (mPtr != 0) {
+        synchronized (mLock) {
             native_delete(mPtr);
             mPtr = 0;
         }
@@ -68,25 +75,50 @@
         Log.i(TAG, "Exited thread for " + getName());
     }
 
+    @Override
+    public void start() {
+        super.start();
+
+        // Wait for native_start
+        waitForStart();
+    }
+
+    private void waitForStart() {
+        int count = POLL_COUNT;
+        while (count-- > 0) {
+            synchronized (mLock) {
+                if (mPtr != 0 && native_is_started(mPtr)) {
+                    return;
+                }
+            }
+            try {
+                Log.v(TAG, "Waiting " + POLL_INTERVAL_MS + "ms for FUSE start. Count " + count);
+                Thread.sleep(POLL_INTERVAL_MS);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                Log.e(TAG, "Interrupted while starting FUSE", e);
+            }
+        }
+        throw new IllegalStateException("Failed to start FUSE");
+    }
+
     /** Waits for any running FUSE sessions to return. */
     public void waitForExit() {
-        Log.i(TAG, "Waiting 5s for thread " + getName() + " to exit...");
+        int waitMs = POLL_COUNT * POLL_INTERVAL_MS;
+        Log.i(TAG, "Waiting " + waitMs + "ms for FUSE " + getName() + " to exit...");
 
         try {
-            join(5000);
+            join(waitMs);
         } catch (InterruptedException e) {
-            Log.e(TAG, "Interrupted while waiting for thread " + getName()
-                    + " to exit. Terminating process", e);
-            System.exit(1);
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("Interrupted while terminating FUSE " + getName());
         }
 
         if (isAlive()) {
-            Log.i(TAG, "Failed to exit thread " + getName()
-                    + " successfully. Terminating process");
-            System.exit(1);
+            throw new IllegalStateException("Failed to exit FUSE " + getName() + " successfully");
         }
 
-        Log.i(TAG, "Exited thread " + getName() + " successfully");
+        Log.i(TAG, "Exited FUSE " + getName() + " successfully");
     }
 
     /**
@@ -96,14 +128,26 @@
      * @return {@code true} if the file should be opened via FUSE, {@code false} otherwise
      */
     public boolean shouldOpenWithFuse(String path, boolean readLock, int fd) {
-        return native_should_open_with_fuse(mPtr, path, readLock, fd);
+        synchronized (mLock) {
+            if (mPtr == 0) {
+                Log.i(TAG, "shouldOpenWithFuse failed, FUSE daemon unavailable");
+                return false;
+            }
+            return native_should_open_with_fuse(mPtr, path, readLock, fd);
+        }
     }
 
     /**
      * Invalidates FUSE VFS dentry cache for {@code path}
      */
     public void invalidateFuseDentryCache(String path) {
-        native_invalidate_fuse_dentry_cache(mPtr, path);
+        synchronized (mLock) {
+            if (mPtr == 0) {
+                Log.i(TAG, "invalidateFuseDentryCache failed, FUSE daemon unavailable");
+                return;
+            }
+            native_invalidate_fuse_dentry_cache(mPtr, path);
+        }
     }
 
     private native long native_new(MediaProvider mediaProvider);
@@ -112,5 +156,6 @@
     private native boolean native_should_open_with_fuse(long daemon, String path, boolean readLock,
             int fd);
     private native void native_invalidate_fuse_dentry_cache(long daemon, String path);
+    private native boolean native_is_started(long daemon);
     public static native boolean native_is_fuse_thread();
 }
diff --git a/src/com/android/providers/media/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java
index a2333cb..81a6a34 100644
--- a/src/com/android/providers/media/scan/ModernMediaScanner.java
+++ b/src/com/android/providers/media/scan/ModernMediaScanner.java
@@ -117,6 +117,7 @@
 import java.util.TimeZone;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
@@ -159,6 +160,11 @@
     private static final Pattern PATTERN_INVISIBLE = Pattern.compile(
             "(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?/Android/(?:data|obb)$");
 
+    private static final Pattern PATTERN_YEAR = Pattern.compile("([1-9][0-9][0-9][0-9])");
+
+    private static final Pattern PATTERN_ALBUM_ART = Pattern.compile(
+            "(?i)(?:(?:^folder|(?:^AlbumArt(?:(?:_\\{.*\\}_)?(?:small|large))?))(?:\\.jpg$)|(?:\\._.*))");
+
     private final Context mContext;
     private final DrmManagerClient mDrmClient;
 
@@ -693,7 +699,10 @@
             return scanItemDirectory(existingId, file, attrs, mimeType, volumeName);
         }
 
-        final int mediaType = MimeUtils.resolveMediaType(mimeType);
+        int mediaType = MimeUtils.resolveMediaType(mimeType);
+        if (mediaType == FileColumns.MEDIA_TYPE_IMAGE && isFileAlbumArt(name)) {
+            mediaType = FileColumns.MEDIA_TYPE_NONE;
+        }
         switch (mediaType) {
             case FileColumns.MEDIA_TYPE_AUDIO:
                 return scanItemAudio(existingId, file, attrs, mimeType, volumeName);
@@ -1056,6 +1065,8 @@
             return Optional.empty();
         } else if (value instanceof String && ((String) value).equals("-1")) {
             return Optional.empty();
+        } else if (value instanceof String && ((String) value).trim().length() == 0) {
+            return Optional.empty();
         } else if (value instanceof Number && ((Number) value).intValue() == -1) {
             return Optional.empty();
         } else {
@@ -1096,6 +1107,7 @@
      * the epoch, making our best guess from unrelated fields when offset
      * information isn't directly available.
      */
+    @VisibleForTesting
     static @NonNull Optional<Long> parseOptionalDateTaken(@NonNull ExifInterface exif,
             long lastModifiedTime) {
         final long originalTime = ExifUtils.getDateTimeOriginal(exif);
@@ -1181,6 +1193,21 @@
         }
     }
 
+    @VisibleForTesting
+    static @NonNull Optional<Integer> parseOptionalYear(@Nullable String value) {
+        final Optional<String> parsedValue = parseOptional(value);
+        if (parsedValue.isPresent()) {
+            final Matcher m = PATTERN_YEAR.matcher(parsedValue.get());
+            if (m.find()) {
+                return Optional.of(Integer.parseInt(m.group(1)));
+            } else {
+                return Optional.empty();
+            }
+        } else {
+            return Optional.empty();
+        }
+    }
+
     private static @NonNull Optional<Integer> parseOptionalTrack(
             @NonNull MediaMetadataRetriever mmr) {
         final Optional<Integer> disc = parseOptionalNumerator(
@@ -1210,6 +1237,12 @@
 
         if (fileMimeType.regionMatches(0, refinedMimeType, 0, refinedSplit + 1)) {
             return Optional.of(refinedMimeType);
+        } else if ("video/mp4".equals(fileMimeType)
+                && "audio/mp4".equals(refinedMimeType)) {
+            // We normally only allow MIME types to be customized when the
+            // top-level type agrees, but this one very narrow case is added to
+            // support a music service that was writing "m4a" files as "mp4".
+            return Optional.of(refinedMimeType);
         } else {
             return Optional.empty();
         }
@@ -1279,6 +1312,11 @@
         return false;
     }
 
+    @VisibleForTesting
+    static boolean isFileAlbumArt(String name) {
+        return PATTERN_ALBUM_ART.matcher(name).matches();
+    }
+
     /**
      * Test if this given {@link Uri} is a
      * {@link android.provider.MediaStore.Audio.Playlists} item.
diff --git a/src/com/android/providers/media/scan/PlaylistResolver.java b/src/com/android/providers/media/scan/PlaylistResolver.java
index e9b6168..d546a80 100644
--- a/src/com/android/providers/media/scan/PlaylistResolver.java
+++ b/src/com/android/providers/media/scan/PlaylistResolver.java
@@ -106,7 +106,8 @@
             while ((line = reader.readLine()) != null) {
                 if (!TextUtils.isEmpty(line) && !line.startsWith("#")) {
                     final int itemIndex = res.size() + 1;
-                    final File itemFile = parentPath.resolve(line).toFile();
+                    final File itemFile = parentPath.resolve(
+                            line.replace('\\', '/')).toFile();
                     try {
                         res.add(resolvePlaylistItem(resolver, uri, itemIndex, itemFile));
                     } catch (FileNotFoundException ignored) {
@@ -130,7 +131,8 @@
                 final Matcher matcher = PATTERN_PLS.matcher(line);
                 if (matcher.matches()) {
                     final int itemIndex = Integer.parseInt(matcher.group(1));
-                    final File itemFile = parentPath.resolve(matcher.group(2)).toFile();
+                    final File itemFile = parentPath.resolve(
+                            matcher.group(2).replace('\\', '/')).toFile();
                     try {
                         res.add(resolvePlaylistItem(resolver, uri, itemIndex, itemFile));
                     } catch (FileNotFoundException ignored) {
@@ -160,7 +162,8 @@
                         final String src = parser.getAttributeValue(null, ATTR_SRC);
                         if (src != null) {
                             final int itemIndex = res.size() + 1;
-                            final File itemFile = parentPath.resolve(src).toFile();
+                            final File itemFile = parentPath.resolve(
+                                    src.replace('\\', '/')).toFile();
                             try {
                                 res.add(resolvePlaylistItem(resolver, uri, itemIndex, itemFile));
                             } catch (FileNotFoundException ignored) {
@@ -179,7 +182,6 @@
             @NonNull ContentResolver resolver, @NonNull Uri uri, int itemIndex, File itemFile)
             throws IOException {
         final Uri audioUri = MediaStore.Audio.Media.getContentUri(MediaStore.getVolumeName(uri));
-        itemFile = new File(itemFile.getAbsolutePath().replace('\\', '/'));
         try (Cursor cursor = resolver.query(audioUri,
                 new String[] { MediaColumns._ID }, MediaColumns.DATA + "=?",
                 new String[] { itemFile.getCanonicalPath() }, null)) {
diff --git a/src/com/android/providers/media/util/SQLiteQueryBuilder.java b/src/com/android/providers/media/util/SQLiteQueryBuilder.java
index fac5112..a8a3565 100644
--- a/src/com/android/providers/media/util/SQLiteQueryBuilder.java
+++ b/src/com/android/providers/media/util/SQLiteQueryBuilder.java
@@ -51,7 +51,7 @@
     private static final String TAG = "SQLiteQueryBuilder";
 
     private static final Pattern sAggregationPattern = Pattern.compile(
-            "(?i)(AVG|COUNT|MAX|MIN|SUM|TOTAL|GROUP_CONCAT)\\((.+)\\)");
+            "(?i)(AVG|COUNT|MAX|MIN|SUM|TOTAL|GROUP_CONCAT|UNICODE)\\((.+)\\)");
 
     private Map<String, String> mProjectionMap = null;
     private Collection<Pattern> mProjectionGreylist = null;
@@ -721,6 +721,7 @@
     }
 
     private void enforceStrictToken(@NonNull String token) {
+        if (TextUtils.isEmpty(token)) return;
         if (isTableOrColumn(token)) return;
         if (SQLiteTokenizer.isFunction(token)) return;
         if (SQLiteTokenizer.isType(token)) return;
diff --git a/tests/jni/FuseDaemonTest/Android.bp b/tests/jni/FuseDaemonTest/Android.bp
index 35cc9df..1208e1d 100644
--- a/tests/jni/FuseDaemonTest/Android.bp
+++ b/tests/jni/FuseDaemonTest/Android.bp
@@ -26,6 +26,22 @@
     sdk_version: "test_current",
     srcs: ["FilePathAccessTestHelper/src/**/*.java"],
 }
+android_test_helper_app {
+    name: "TestAppC",
+    manifest: "FilePathAccessTestHelper/TestAppC.xml",
+    static_libs: ["androidx.test.rules", "tests-fusedaemon-lib"],
+    sdk_version: "test_current",
+    srcs: ["FilePathAccessTestHelper/src/**/*.java"],
+}
+android_test_helper_app {
+    name: "TestAppCLegacy",
+    manifest: "FilePathAccessTestHelper/TestAppCLegacy.xml",
+    static_libs: ["androidx.test.rules", "tests-fusedaemon-lib"],
+    sdk_version: "test_current",
+    target_sdk_version: "28",
+    srcs: ["FilePathAccessTestHelper/src/**/*.java"],
+}
+
 android_test {
     name: "FuseDaemonTest",
     manifest: "AndroidManifest.xml",
@@ -36,9 +52,10 @@
     java_resources: [
         ":TestAppA",
         ":TestAppB",
+        ":TestAppC",
+        ":TestAppCLegacy",
     ]
 }
-
 android_test {
     name: "FuseDaemonLegacyTest",
     manifest: "legacy/AndroidManifest.xml",
diff --git a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppC.xml b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppC.xml
new file mode 100644
index 0000000..f3f3c3d
--- /dev/null
+++ b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppC.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.tests.fused.testapp.C"
+    android:versionCode="1"
+    android:versionName="1.0" >
+
+  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
+  <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
+
+    <application android:label="TestAppC">
+        <activity android:name="com.android.tests.fused.FilePathAccessTestHelper">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT"/>
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppCLegacy.xml b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppCLegacy.xml
new file mode 100644
index 0000000..a31ee47
--- /dev/null
+++ b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppCLegacy.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.tests.fused.testapp.C"
+    android:versionCode="1"
+    android:versionName="1.0" >
+
+  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+
+    <application android:label="TestAppCLegacy">
+        <activity android:name="com.android.tests.fused.FilePathAccessTestHelper">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT"/>
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/src/com/android/tests/fused/FilePathAccessTestHelper.java b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/src/com/android/tests/fused/FilePathAccessTestHelper.java
index 7f6a485..f7af146 100644
--- a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/src/com/android/tests/fused/FilePathAccessTestHelper.java
+++ b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/src/com/android/tests/fused/FilePathAccessTestHelper.java
@@ -22,7 +22,10 @@
 import static com.android.tests.fused.lib.TestUtils.DELETE_FILE_QUERY;
 import static com.android.tests.fused.lib.TestUtils.INTENT_EXCEPTION;
 import static com.android.tests.fused.lib.TestUtils.INTENT_EXTRA_PATH;
+import static com.android.tests.fused.lib.TestUtils.OPEN_FILE_FOR_READ_QUERY;
+import static com.android.tests.fused.lib.TestUtils.OPEN_FILE_FOR_WRITE_QUERY;
 import static com.android.tests.fused.lib.TestUtils.QUERY_TYPE;
+import static com.android.tests.fused.lib.TestUtils.canOpen;
 
 import android.app.Activity;
 import android.content.Intent;
@@ -54,7 +57,9 @@
                 break;
             case CREATE_FILE_QUERY:
             case DELETE_FILE_QUERY:
-                createOrDeleteFile(queryType);
+            case OPEN_FILE_FOR_READ_QUERY:
+            case OPEN_FILE_FOR_WRITE_QUERY:
+                accessFile(queryType);
                 break;
             case EXIF_METADATA_QUERY:
                 sendMetadata(queryType);
@@ -99,7 +104,7 @@
         }
     }
 
-    private void createOrDeleteFile(String queryType) {
+    private void accessFile(String queryType) {
         if (getIntent().hasExtra(INTENT_EXTRA_PATH)) {
             final String filePath = getIntent().getStringExtra(INTENT_EXTRA_PATH);
             final File file = new File(filePath);
@@ -109,9 +114,13 @@
                     returnStatus = file.createNewFile();
                 } else if (queryType.equals(DELETE_FILE_QUERY)) {
                     returnStatus = file.delete();
+                } else if (queryType.equals(OPEN_FILE_FOR_READ_QUERY)) {
+                    returnStatus = canOpen(file, false /* forWrite */);
+                } else if (queryType.equals(OPEN_FILE_FOR_WRITE_QUERY)) {
+                    returnStatus = canOpen(file, true /* forWrite */);
                 }
             } catch(IOException e) {
-                Log.e(TAG, "IOException occurred while creating/deleting " + filePath);
+                Log.e(TAG, "Failed to access file: " + filePath + ". Query type: " + queryType, e);
             }
             final Intent intent = new Intent(queryType);
             intent.putExtra(queryType, returnStatus);
diff --git a/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java b/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java
index 10b063a..9d8a1a0 100644
--- a/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java
+++ b/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java
@@ -151,6 +151,23 @@
     }
 
     @Test
+    public void testCallingIdentityCacheInvalidation() throws Exception {
+        // General IO access
+        runDeviceTest("testReadStorageInvalidation");
+        runDeviceTest("testWriteStorageInvalidation");
+        // File manager access
+        runDeviceTest("testManageStorageInvalidation");
+        // Default gallery
+        runDeviceTest("testWriteImagesInvalidation");
+        runDeviceTest("testWriteVideoInvalidation");
+        // EXIF access
+        runDeviceTest("testAccessMediaLocationInvalidation");
+
+        runDeviceTest("testAppUpdateInvalidation");
+        runDeviceTest("testAppReinstallInvalidation");
+    }
+
+    @Test
     public void testRenameFile() throws Exception {
         runDeviceTest("testRenameFile");
     }
diff --git a/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java b/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java
index 75735a5..97e39d4 100644
--- a/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java
+++ b/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java
@@ -62,6 +62,7 @@
 import java.io.File;
 import java.io.FileDescriptor;
 import java.io.FileInputStream;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -80,7 +81,8 @@
     public static final String INTENT_EXCEPTION = "com.android.tests.fused.exception";
     public static final String CREATE_FILE_QUERY = "com.android.tests.fused.createfile";
     public static final String DELETE_FILE_QUERY = "com.android.tests.fused.deletefile";
-
+    public static final String OPEN_FILE_FOR_READ_QUERY = "com.android.tests.fused.openfile_read";
+    public static final String OPEN_FILE_FOR_WRITE_QUERY = "com.android.tests.fused.openfile_write";
 
     public static final String STR_DATA1 = "Just some random text";
     public static final String STR_DATA2 = "More arbitrary stuff";
@@ -91,47 +93,47 @@
     private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
     private static final long POLLING_SLEEP_MILLIS = 100;
 
-    private static final UiAutomation sUiAutomation = InstrumentationRegistry.getInstrumentation()
-            .getUiAutomation();
-
     /**
      * Grants {@link Manifest.permission#GRANT_RUNTIME_PERMISSIONS} to the given package.
      */
-    public static void grantReadExternalStorage(String packageName) {
-        sUiAutomation.adoptShellPermissionIdentity("android.permission.GRANT_RUNTIME_PERMISSIONS");
+    public static void grantPermission(String packageName, String permission) {
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+        uiAutomation.adoptShellPermissionIdentity("android.permission.GRANT_RUNTIME_PERMISSIONS");
         try {
-            sUiAutomation.grantRuntimePermission(packageName,
-                    Manifest.permission.READ_EXTERNAL_STORAGE);
+            uiAutomation.grantRuntimePermission(packageName, permission);
             // Wait for OP_READ_EXTERNAL_STORAGE to get updated.
             SystemClock.sleep(1000);
         } finally {
-            sUiAutomation.dropShellPermissionIdentity();
+            uiAutomation.dropShellPermissionIdentity();
         }
     }
 
     /**
      * Revokes {@link Manifest.permission#GRANT_RUNTIME_PERMISSIONS} from the given package.
      */
-    public static void revokeReadExternalStorage(String packageName) {
-        sUiAutomation.adoptShellPermissionIdentity("android.permission.REVOKE_RUNTIME_PERMISSIONS");
+    public static void revokePermission(String packageName, String permission) {
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+        uiAutomation.adoptShellPermissionIdentity("android.permission.REVOKE_RUNTIME_PERMISSIONS");
         try {
-            sUiAutomation.revokeRuntimePermission(packageName,
-                    Manifest.permission.READ_EXTERNAL_STORAGE);
+            uiAutomation.revokeRuntimePermission(packageName, permission);
         } finally {
-            sUiAutomation.dropShellPermissionIdentity();
+            uiAutomation.dropShellPermissionIdentity();
         }
     }
 
     public static void adoptShellPermissionIdentity(String... permissions) {
-        sUiAutomation.adoptShellPermissionIdentity(permissions);
+        InstrumentationRegistry.getInstrumentation().getUiAutomation()
+                .adoptShellPermissionIdentity(permissions);
     }
 
     public static void dropShellPermissionIdentity() {
-        sUiAutomation.dropShellPermissionIdentity();
+        InstrumentationRegistry.getInstrumentation().getUiAutomation()
+                .dropShellPermissionIdentity();
     }
 
     public static String executeShellCommand(String cmd) throws Exception {
-        try (FileInputStream output = new FileInputStream (sUiAutomation.executeShellCommand(cmd)
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+        try (FileInputStream output = new FileInputStream (uiAutomation.executeShellCommand(cmd)
                 .getFileDescriptor())) {
             return new String(ByteStreams.toByteArray(output));
         }
@@ -166,7 +168,7 @@
      * <p>This method drops shell permission identity.
      */
     public static boolean createFileAs(TestApp testApp, String path) throws Exception {
-        return createOrDeleteFileFromTestApp(testApp, path, CREATE_FILE_QUERY);
+        return getResultFromTestApp(testApp, path, CREATE_FILE_QUERY);
     }
 
     /**
@@ -175,7 +177,7 @@
      * <p>This method drops shell permission identity.
      */
     public static boolean deleteFileAs(TestApp testApp, String path) throws Exception {
-        return createOrDeleteFileFromTestApp(testApp, path, DELETE_FILE_QUERY);
+        return getResultFromTestApp(testApp, path, DELETE_FILE_QUERY);
     }
 
     /**
@@ -192,14 +194,25 @@
     }
 
     /**
+     * Makes the given {@code testApp} open a file for read or write.
+     *
+     * <p>This method drops shell permission identity.
+     */
+    public static boolean openFileAs(TestApp testApp, String path, boolean forWrite)
+            throws Exception {
+        return getResultFromTestApp(testApp, path,
+                forWrite ? OPEN_FILE_FOR_WRITE_QUERY : OPEN_FILE_FOR_READ_QUERY);
+    }
+
+    /**
      * Installs a {@link TestApp} and may grant it storage permissions.
      */
     public static void installApp(TestApp testApp, boolean grantStoragePermission)
             throws Exception {
-
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
         try {
             final String packageName = testApp.getPackageName();
-            sUiAutomation.adoptShellPermissionIdentity(Manifest.permission.INSTALL_PACKAGES,
+            uiAutomation.adoptShellPermissionIdentity(Manifest.permission.INSTALL_PACKAGES,
                     Manifest.permission.DELETE_PACKAGES);
             if (InstallUtils.getInstalledVersion(packageName) != -1) {
                 Uninstall.packages(packageName);
@@ -207,10 +220,10 @@
             Install.single(testApp).commit();
             assertThat(InstallUtils.getInstalledVersion(packageName)).isEqualTo(1);
             if (grantStoragePermission) {
-                grantReadExternalStorage(packageName);
+                grantPermission(packageName, Manifest.permission.READ_EXTERNAL_STORAGE);
             }
         } finally {
-            sUiAutomation.dropShellPermissionIdentity();
+            uiAutomation.dropShellPermissionIdentity();
         }
     }
 
@@ -218,14 +231,15 @@
      * Uninstalls a {@link TestApp}.
      */
     public static void uninstallApp(TestApp testApp) throws Exception {
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
         try {
             final String packageName = testApp.getPackageName();
-            sUiAutomation.adoptShellPermissionIdentity(Manifest.permission.DELETE_PACKAGES);
+            uiAutomation.adoptShellPermissionIdentity(Manifest.permission.DELETE_PACKAGES);
 
             Uninstall.packages(packageName);
             assertThat(InstallUtils.getInstalledVersion(packageName)).isEqualTo(-1);
         } finally {
-            sUiAutomation.dropShellPermissionIdentity();
+            uiAutomation.dropShellPermissionIdentity();
         }
     }
 
@@ -456,6 +470,22 @@
         }
     }
 
+    public static boolean canOpen(File file, boolean forWrite) {
+        if (forWrite) {
+            try (FileOutputStream fis = new FileOutputStream(file)) {
+                return true;
+            } catch (IOException expected) {
+                return false;
+            }
+        } else {
+            try (FileInputStream fis = new FileInputStream(file)) {
+                return true;
+            } catch (IOException expected) {
+                return false;
+            }
+        }
+    }
+
     public static void pollForExternalStorageState() throws Exception {
         for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
             if(Environment.getExternalStorageState(Environment.getExternalStorageDirectory())
@@ -534,13 +564,14 @@
      * <p>This method drops shell permission identity.
      */
     private static void forceStopApp(String packageName) throws Exception {
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
         try {
-            sUiAutomation.adoptShellPermissionIdentity(Manifest.permission.FORCE_STOP_PACKAGES);
+            uiAutomation.adoptShellPermissionIdentity(Manifest.permission.FORCE_STOP_PACKAGES);
 
             getContext().getSystemService(ActivityManager.class).forceStopPackage(packageName);
             Thread.sleep(1000);
         } finally {
-            sUiAutomation.dropShellPermissionIdentity();
+            uiAutomation.dropShellPermissionIdentity();
         }
     }
 
@@ -621,7 +652,7 @@
     /**
      * <p>This method drops shell permission identity.
      */
-    private static boolean createOrDeleteFileFromTestApp(TestApp testApp, String dirPath,
+    private static boolean getResultFromTestApp(TestApp testApp, String dirPath,
             String actionName) throws Exception {
         final CountDownLatch latch = new CountDownLatch(1);
         final boolean[] appOutput = new boolean[1];
@@ -668,4 +699,4 @@
         assertThat(c).isNotNull();
         return c;
     }
-}
\ No newline at end of file
+}
diff --git a/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java b/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
index 54caffe..f676780 100644
--- a/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
+++ b/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
@@ -16,6 +16,7 @@
 
 package com.android.tests.fused;
 
+import static android.app.AppOpsManager.permissionToOp;
 import static android.os.SystemProperties.getBoolean;
 import static android.provider.MediaStore.MediaColumns;
 
@@ -36,6 +37,7 @@
 import static com.android.tests.fused.lib.TestUtils.assertCantRenameFile;
 import static com.android.tests.fused.lib.TestUtils.assertFileContent;
 import static com.android.tests.fused.lib.TestUtils.assertThrows;
+import static com.android.tests.fused.lib.TestUtils.canOpen;
 import static com.android.tests.fused.lib.TestUtils.createFileAs;
 import static com.android.tests.fused.lib.TestUtils.deleteFileAs;
 import static com.android.tests.fused.lib.TestUtils.deleteFileAsNoThrow;
@@ -47,11 +49,13 @@
 import static com.android.tests.fused.lib.TestUtils.getFileMimeTypeFromDatabase;
 import static com.android.tests.fused.lib.TestUtils.getFileRowIdFromDatabase;
 import static com.android.tests.fused.lib.TestUtils.getFileUri;
+import static com.android.tests.fused.lib.TestUtils.grantPermission;
 import static com.android.tests.fused.lib.TestUtils.installApp;
 import static com.android.tests.fused.lib.TestUtils.listAs;
+import static com.android.tests.fused.lib.TestUtils.openFileAs;
 import static com.android.tests.fused.lib.TestUtils.openWithMediaProvider;
 import static com.android.tests.fused.lib.TestUtils.readExifMetadataFromTestApp;
-import static com.android.tests.fused.lib.TestUtils.revokeReadExternalStorage;
+import static com.android.tests.fused.lib.TestUtils.revokePermission;
 import static com.android.tests.fused.lib.TestUtils.uninstallApp;
 import static com.android.tests.fused.lib.TestUtils.uninstallAppNoThrow;
 import static com.android.tests.fused.lib.TestUtils.updateDisplayNameWithMediaProvider;
@@ -63,6 +67,7 @@
 
 import static org.junit.Assume.assumeTrue;
 
+import android.Manifest;
 import android.app.AppOpsManager;
 import android.content.ContentResolver;
 import android.database.Cursor;
@@ -77,6 +82,7 @@
 import android.system.OsConstants;
 import android.util.Log;
 
+import androidx.annotation.Nullable;
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.cts.install.lib.TestApp;
@@ -89,7 +95,6 @@
 
 import java.io.File;
 import java.io.FileDescriptor;
-import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -139,10 +144,14 @@
             "com.android.tests.fused.testapp.A", 1, false, "TestAppA.apk");
     private static final TestApp TEST_APP_B  = new TestApp("TestAppB",
             "com.android.tests.fused.testapp.B", 1, false, "TestAppB.apk");
+    private static final TestApp TEST_APP_C  = new TestApp("TestAppC",
+            "com.android.tests.fused.testapp.C", 1, false, "TestAppC.apk");
+    private static final TestApp TEST_APP_C_LEGACY  = new TestApp("TestAppCLegacy",
+            "com.android.tests.fused.testapp.C", 1, false, "TestAppCLegacy.apk");
     private static final String[] SYSTEM_GALERY_APPOPS = { AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES,
             AppOpsManager.OPSTR_WRITE_MEDIA_VIDEO };
-    //TODO(b/150115615): used AppOpsManager#OPSTR_MANAGE_EXTERNAL_STORAGE once it's public API
-    private static final String OPSTR_MANAGE_EXTERNAL_STORAGE = "android:manage_external_storage";
+    private static final String OPSTR_MANAGE_EXTERNAL_STORAGE =
+            permissionToOp(Manifest.permission.MANAGE_EXTERNAL_STORAGE);
 
     @Before
     public void setUp() throws Exception {
@@ -380,22 +389,10 @@
             assertThat(nonMediaFile.exists()).isTrue();
 
             // But we can't access their content
-            try (FileInputStream fis = new FileInputStream(mediaFile)) {
-                fail("Opening for read succeeded when it should have failed: " + mediaFile);
-            } catch (IOException expected) {}
-
-            try (FileInputStream fis = new FileInputStream(nonMediaFile)) {
-                fail("Opening for read succeeded when it should have failed: " + nonMediaFile);
-            } catch (IOException expected) {}
-
-            try (FileOutputStream fos = new FileOutputStream(mediaFile)) {
-                fail("Opening for write succeeded when it should have failed: " + mediaFile);
-            } catch (IOException expected) {}
-
-            try (FileOutputStream fos = new FileOutputStream(nonMediaFile)) {
-                fail("Opening for write succeeded when it should have failed: " + nonMediaFile);
-            } catch (IOException expected) {}
-
+            assertThat(canOpen(mediaFile, /* forWrite */ false)).isFalse();
+            assertThat(canOpen(nonMediaFile, /* forWrite */ true)).isFalse();
+            assertThat(canOpen(mediaFile, /* forWrite */ false)).isFalse();
+            assertThat(canOpen(nonMediaFile, /* forWrite */ true)).isFalse();
         } finally {
             deleteFileAsNoThrow(TEST_APP_A, nonMediaFile.getPath());
             deleteFileAsNoThrow(TEST_APP_A, mediaFile.getPath());
@@ -560,7 +557,8 @@
             assertThat(listAs(TEST_APP_B, dir.getPath())).containsExactly(videoFileName);
 
             // Revoke storage permission for TEST_APP_B
-            revokeReadExternalStorage(TEST_APP_B.getPackageName());
+            revokePermission(TEST_APP_B.getPackageName(),
+                    Manifest.permission.READ_EXTERNAL_STORAGE);
             // TEST_APP_B without storage permission should see TEST_DIRECTORY in DCIM and should
             // not see new file in new TEST_DIRECTORY.
             assertThat(listAs(TEST_APP_B, DCIM_DIR.getPath())).contains(TEST_DIRECTORY_NAME);
@@ -908,6 +906,175 @@
     }
 
     @Test
+    public void testReadStorageInvalidation() throws Exception {
+        testAppOpInvalidation(TEST_APP_C, new File(DCIM_DIR, "read_storage.jpg"),
+                Manifest.permission.READ_EXTERNAL_STORAGE,
+                AppOpsManager.OPSTR_READ_EXTERNAL_STORAGE, /* forWrite */ false);
+    }
+
+    @Test
+    public void testWriteStorageInvalidation() throws Exception {
+        testAppOpInvalidation(TEST_APP_C_LEGACY, new File(DCIM_DIR, "write_storage.jpg"),
+                Manifest.permission.WRITE_EXTERNAL_STORAGE,
+                AppOpsManager.OPSTR_WRITE_EXTERNAL_STORAGE, /* forWrite */ true);
+    }
+
+    @Test
+    public void testManageStorageInvalidation() throws Exception {
+        testAppOpInvalidation(TEST_APP_C, new File(DOWNLOAD_DIR, "manage_storage.pdf"),
+                /* permission */ null, OPSTR_MANAGE_EXTERNAL_STORAGE, /* forWrite */ true);
+    }
+
+    @Test
+    public void testWriteImagesInvalidation() throws Exception {
+        testAppOpInvalidation(TEST_APP_C, new File(DCIM_DIR, "write_images.jpg"),
+                /* permission */ null, AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES, /* forWrite */ true);
+    }
+
+    @Test
+    public void testWriteVideoInvalidation() throws Exception {
+        testAppOpInvalidation(TEST_APP_C, new File(DCIM_DIR, "write_video.mp4"),
+                /* permission */ null, AppOpsManager.OPSTR_WRITE_MEDIA_VIDEO, /* forWrite */ true);
+    }
+
+    @Test
+    public void testAccessMediaLocationInvalidation() throws Exception {
+        File imgFile = new File(DCIM_DIR, "access_media_location.jpg");
+
+        try {
+            // Setup image with sensitive data on external storage
+            HashMap<String, String> originalExif = getExifMetadataFromRawResource(
+                    R.raw.img_with_metadata);
+            try (InputStream in = getContext().getResources().openRawResource(
+                    R.raw.img_with_metadata);
+                 OutputStream out = new FileOutputStream(imgFile)) {
+                // Dump the image we have to external storage
+                FileUtils.copy(in, out);
+            }
+            HashMap<String, String> exif = getExifMetadata(imgFile);
+            assertExifMetadataMatch(exif, originalExif);
+
+            // Install test app
+            installApp(TEST_APP_C, /* grantStoragePermissions */ true);
+
+            // Grant A_M_L and verify access to sensitive data
+            grantPermission(TEST_APP_C.getPackageName(), Manifest.permission.ACCESS_MEDIA_LOCATION);
+            HashMap<String, String> exifFromTestApp = readExifMetadataFromTestApp(TEST_APP_C,
+                    imgFile.getPath());
+            assertExifMetadataMatch(exifFromTestApp, originalExif);
+
+            // Revoke A_M_L and verify sensitive data redaction
+            revokePermission(TEST_APP_C.getPackageName(),
+                    Manifest.permission.ACCESS_MEDIA_LOCATION);
+            exifFromTestApp = readExifMetadataFromTestApp(TEST_APP_C,
+                    imgFile.getPath());
+            assertExifMetadataMismatch(exifFromTestApp, originalExif);
+
+            // Re-grant A_M_L and verify access to sensitive data
+            grantPermission(TEST_APP_C.getPackageName(), Manifest.permission.ACCESS_MEDIA_LOCATION);
+            exifFromTestApp = readExifMetadataFromTestApp(TEST_APP_C,
+                    imgFile.getPath());
+            assertExifMetadataMatch(exifFromTestApp, originalExif);
+        } finally {
+            imgFile.delete();
+            uninstallAppNoThrow(TEST_APP_C);
+        }
+    }
+
+    @Test
+    public void testAppUpdateInvalidation() throws Exception {
+        File file = new File(DCIM_DIR, "app_update.jpg");
+        try {
+            assertThat(file.createNewFile()).isTrue();
+
+            // Install legacy
+            installApp(TEST_APP_C_LEGACY, /* grantStoragePermissions */ true);
+            grantPermission(TEST_APP_C_LEGACY.getPackageName(),
+                    Manifest.permission.WRITE_EXTERNAL_STORAGE); // Grants write access for legacy
+            // Legacy app can read and write media files contributed by others
+            assertThat(openFileAs(TEST_APP_C_LEGACY, file.getPath(), /* forWrite */ false))
+                    .isTrue();
+            assertThat(openFileAs(TEST_APP_C_LEGACY, file.getPath(), /* forWrite */ true)).isTrue();
+
+            // Update to non-legacy
+            installApp(TEST_APP_C, /* grantStoragePermissions */ true);
+            grantPermission(TEST_APP_C_LEGACY.getPackageName(),
+                    Manifest.permission.WRITE_EXTERNAL_STORAGE); // No effect for non-legacy
+            // Non-legacy app can read media files contributed by others
+            assertThat(openFileAs(TEST_APP_C, file.getPath(), /* forWrite */ false)).isTrue();
+            // But cannot write
+            assertThat(openFileAs(TEST_APP_C, file.getPath(), /* forWrite */ true)).isFalse();
+        } finally {
+            file.delete();
+            uninstallAppNoThrow(TEST_APP_C);
+        }
+    }
+
+    @Test
+    public void testAppReinstallInvalidation() throws Exception {
+        File file = new File(DCIM_DIR, "app_reinstall.jpg");
+
+        try {
+            assertThat(file.createNewFile()).isTrue();
+
+            // Install
+            installApp(TEST_APP_C, /* grantStoragePermissions */ true);
+            assertThat(openFileAs(TEST_APP_C, file.getPath(), /* forWrite */ false)).isTrue();
+
+            // Re-install
+            uninstallAppNoThrow(TEST_APP_C);
+            installApp(TEST_APP_C, /* grantStoragePermissions */ false);
+            assertThat(openFileAs(TEST_APP_C, file.getPath(), /* forWrite */ false)).isFalse();
+        } finally {
+            file.delete();
+            uninstallAppNoThrow(TEST_APP_C);
+        }
+    }
+
+    private void testAppOpInvalidation(TestApp app, File file, @Nullable String permission,
+            String opstr, boolean forWrite) throws Exception {
+        try {
+            installApp(app, false);
+            assertThat(file.createNewFile()).isTrue();
+            assertAppOpInvalidation(app, file, permission, opstr, forWrite);
+        } finally {
+            file.delete();
+            uninstallApp(app);
+        }
+    }
+
+    /** If {@code permission} is null, appops are flipped, otherwise permissions are flipped */
+    private void assertAppOpInvalidation(TestApp app, File file, @Nullable String permission,
+            String opstr, boolean forWrite) throws Exception {
+        String packageName = app.getPackageName();
+        int uid = getContext().getPackageManager().getPackageUid(packageName, 0);
+
+        // Deny
+        if (permission != null) {
+            revokePermission(packageName, permission);
+        } else {
+            denyAppOpsToUid(uid, opstr);
+        }
+        assertThat(openFileAs(app, file.getPath(), forWrite)).isFalse();
+
+        // Grant
+        if (permission != null) {
+            grantPermission(packageName, permission);
+        } else {
+            allowAppOpsToUid(uid, opstr);
+        }
+        assertThat(openFileAs(app, file.getPath(), forWrite)).isTrue();
+
+        // Deny
+        if (permission != null) {
+            revokePermission(packageName, permission);
+        } else {
+            denyAppOpsToUid(uid, opstr);
+        }
+        assertThat(openFileAs(app, file.getPath(), forWrite)).isFalse();
+    }
+
+    @Test
     public void testSystemGalleryAppHasFullAccessToImages() throws Exception {
         final File otherAppImageFile = new File(DCIM_DIR, "other_" + IMAGE_FILE_NAME);
         final File topLevelImageFile = new File(EXTERNAL_STORAGE_DIR, IMAGE_FILE_NAME);
@@ -961,16 +1128,9 @@
             assertThat(createFileAs(TEST_APP_A, otherAppAudioFile.getPath())).isTrue();
             assertThat(otherAppAudioFile.exists()).isTrue();
 
-            // Assert we can't write to the file
-            try (FileInputStream fis = new FileInputStream(otherAppAudioFile)) {
-                fail("Opening for read succeeded when it should have failed: " + otherAppAudioFile);
-            } catch (IOException expected) {}
-
-            // Assert we can't read from the file
-            try (FileOutputStream fos = new FileOutputStream(otherAppAudioFile)) {
-                fail("Opening for write succeeded when it should have failed: "
-                        + otherAppAudioFile);
-            } catch (IOException expected) {}
+            // Assert we can't access the file
+            assertThat(canOpen(otherAppAudioFile, /* forWrite */ false)).isFalse();
+            assertThat(canOpen(otherAppAudioFile, /* forWrite */ true)).isFalse();
 
             // Assert we can't delete the file
             assertThat(otherAppAudioFile.delete()).isFalse();
diff --git a/tests/res/raw/test_audio_empty_title.mp3 b/tests/res/raw/test_audio_empty_title.mp3
new file mode 100644
index 0000000..1051f66
--- /dev/null
+++ b/tests/res/raw/test_audio_empty_title.mp3
Binary files differ
diff --git a/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java b/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java
index 9ecc6fc..b66a50b 100644
--- a/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/LegacyMediaScannerTest.java
@@ -54,4 +54,39 @@
         } catch (UnsupportedOperationException expected) {
         }
     }
+
+    /**
+      * This implementation was copied verbatim from the legacy
+      * {@code frameworks/base/media/java/android/media/MediaScanner.java}.
+      */
+    static boolean isNonMediaFile(String path) {
+        // special case certain file names
+        // I use regionMatches() instead of substring() below
+        // to avoid memory allocation
+        final int lastSlash = path.lastIndexOf('/');
+        if (lastSlash >= 0 && lastSlash + 2 < path.length()) {
+            // ignore those ._* files created by MacOS
+            if (path.regionMatches(lastSlash + 1, "._", 0, 2)) {
+                return true;
+            }
+
+            // ignore album art files created by Windows Media Player:
+            // Folder.jpg, AlbumArtSmall.jpg, AlbumArt_{...}_Large.jpg
+            // and AlbumArt_{...}_Small.jpg
+            if (path.regionMatches(true, path.length() - 4, ".jpg", 0, 4)) {
+                if (path.regionMatches(true, lastSlash + 1, "AlbumArt_{", 0, 10) ||
+                        path.regionMatches(true, lastSlash + 1, "AlbumArt.", 0, 9)) {
+                    return true;
+                }
+                int length = path.length() - lastSlash - 1;
+                if ((length == 17 && path.regionMatches(
+                        true, lastSlash + 1, "AlbumArtSmall", 0, 13)) ||
+                        (length == 10
+                         && path.regionMatches(true, lastSlash + 1, "Folder", 0, 6))) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
 }
diff --git a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
index f1aa936..b874011 100644
--- a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
@@ -19,8 +19,10 @@
 import static com.android.providers.media.scan.MediaScanner.REASON_UNKNOWN;
 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.parseOptionalDateTaken;
 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalMimeType;
+import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalYear;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -55,6 +57,7 @@
 
 import java.io.File;
 import java.io.FileOutputStream;
+import java.util.Optional;
 
 @RunWith(AndroidJUnit4.class)
 public class ModernMediaScannerTest {
@@ -109,6 +112,21 @@
     }
 
     @Test
+    public void testOverrideMimeType_148316354() throws Exception {
+        // Radical file type shifting isn't allowed
+        assertEquals(Optional.empty(),
+                parseOptionalMimeType("video/mp4", "audio/mpeg"));
+
+        // One specific narrow type of shift (mp4 -> m4a) is allowed
+        assertEquals(Optional.of("audio/mp4"),
+                parseOptionalMimeType("video/mp4", "audio/mp4"));
+
+        // The other direction isn't allowed
+        assertEquals(Optional.empty(),
+                parseOptionalMimeType("audio/mp4", "video/mp4"));
+    }
+
+    @Test
     public void testParseDateTaken_Complete() throws Exception {
         final File file = File.createTempFile("test", ".jpg");
         final ExifInterface exif = new ExifInterface(file);
@@ -205,6 +223,42 @@
         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
     }
 
+    @Test
+    public void testParseYear_Invalid() throws Exception {
+        assertEquals(Optional.empty(), parseOptionalYear(null));
+        assertEquals(Optional.empty(), parseOptionalYear(""));
+        assertEquals(Optional.empty(), parseOptionalYear(" "));
+        assertEquals(Optional.empty(), parseOptionalYear("meow"));
+
+        assertEquals(Optional.empty(), parseOptionalYear("0"));
+        assertEquals(Optional.empty(), parseOptionalYear("00"));
+        assertEquals(Optional.empty(), parseOptionalYear("000"));
+        assertEquals(Optional.empty(), parseOptionalYear("0000"));
+
+        assertEquals(Optional.empty(), parseOptionalYear("1"));
+        assertEquals(Optional.empty(), parseOptionalYear("01"));
+        assertEquals(Optional.empty(), parseOptionalYear("001"));
+        assertEquals(Optional.empty(), parseOptionalYear("0001"));
+
+        // No sane way to determine year from two-digit date formats
+        assertEquals(Optional.empty(), parseOptionalYear("01-01-01"));
+
+        // Specific example from partner
+        assertEquals(Optional.empty(), parseOptionalYear("000 "));
+    }
+
+    @Test
+    public void testParseYear_Valid() throws Exception {
+        assertEquals(Optional.of(1900), parseOptionalYear("1900"));
+        assertEquals(Optional.of(2020), parseOptionalYear("2020"));
+        assertEquals(Optional.of(2020), parseOptionalYear(" 2020 "));
+        assertEquals(Optional.of(2020), parseOptionalYear("01-01-2020"));
+
+        // Specific examples from partner
+        assertEquals(Optional.of(1984), parseOptionalYear("1984-06-26T07:00:00Z"));
+        assertEquals(Optional.of(2016), parseOptionalYear("Thu, 01 Sep 2016 10:11:12.123456 -0500"));
+    }
+
     private static void assertDirectoryHidden(File file) {
         assertTrue(file.getAbsolutePath(), isDirectoryHidden(file));
     }
@@ -487,4 +541,58 @@
             assertEquals(expected, cursor.getCount());
         }
     }
+
+    @Test
+    public void testScan_audio_empty_title() throws Exception {
+        final File music = new File(mDir, "Music");
+        final File audio = new File(music, "audio.mp3");
+
+        music.mkdirs();
+        stage(R.raw.test_audio_empty_title, audio);
+
+        mModern.scanFile(audio, REASON_UNKNOWN);
+
+        try (Cursor cursor = mIsolatedResolver
+                .query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
+            assertEquals(1, cursor.getCount());
+            cursor.moveToFirst();
+            assertEquals("audio", cursor.getString(cursor.getColumnIndex(MediaColumns.TITLE)));
+        }
+    }
+
+    @Test
+    public void testAlbumArtPattern() throws Exception {
+        for (String path : new String[] {
+                "/storage/emulated/0/._abc",
+                "/storage/emulated/0/a._abc",
+
+                "/storage/emulated/0/AlbumArtSmall.jpg",
+                "/storage/emulated/0/albumartsmall.jpg",
+
+                "/storage/emulated/0/AlbumArt_{}_Small.jpg",
+                "/storage/emulated/0/albumart_{a}_small.jpg",
+                "/storage/emulated/0/AlbumArt_{}_Large.jpg",
+                "/storage/emulated/0/albumart_{a}_large.jpg",
+
+                "/storage/emulated/0/Folder.jpg",
+                "/storage/emulated/0/folder.jpg",
+
+                "/storage/emulated/0/AlbumArt.jpg",
+                "/storage/emulated/0/albumart.jpg",
+                "/storage/emulated/0/albumart1.jpg",
+        }) {
+            final File file = new File(path);
+            final String name = file.getName();
+            assertEquals(LegacyMediaScannerTest.isNonMediaFile(path), isFileAlbumArt(name));
+        }
+
+        for (String path : new String[] {
+                "/storage/emulated/0/AlbumArtLarge.jpg",
+                "/storage/emulated/0/albumartlarge.jpg",
+        }) {
+            final File file = new File(path);
+            final String name = file.getName();
+            assertTrue(isFileAlbumArt(name));
+        }
+    }
 }