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));
+ }
+ }
}