Merge changes I4995a7f3,I4c72aa5b,I2c10798f,I304ef683,I5a3be3e6, ... into sc-mainline-prod
* changes:
Add mime type filter test for PhotoPicker
Add setMinimumSpanCount in AutoFitRecyclerView
Add UI coverage for work profile button
Modify Photo Picker return values
Sort based on _ID for rows with same date_taken or date_modified value
Minor refactoring in ItemsProviderTests
Add few UI tests
Extend the 'picker' URI format
Fix transcoding lookup result error
Pass correct key for media generation in partial sync query
Add few UI tests
Allow tests to mock ViewModel
Support synthetic picker file lookups
Add margin for Preview ViewPager
No-op refactor synthetic path utils
Allow FUSE file opening in Java
Allow direct IO on public volumes
Modify API behavior for PhotoPicker multi-selection flow
diff --git a/apex/framework/api/current.txt b/apex/framework/api/current.txt
index 76f3da4..1ada3d3 100644
--- a/apex/framework/api/current.txt
+++ b/apex/framework/api/current.txt
@@ -73,6 +73,7 @@
method public static android.net.Uri getMediaScannerUri();
method @Nullable public static android.net.Uri getMediaUri(@NonNull android.content.Context, @NonNull android.net.Uri);
method @NonNull public static android.os.ParcelFileDescriptor getOriginalMediaFormatFileDescriptor(@NonNull android.content.Context, @NonNull android.os.ParcelFileDescriptor) throws java.io.IOException;
+ method public static int getPickImagesMaxLimit();
method @NonNull public static java.util.Set<java.lang.String> getRecentExternalVolumeNames(@NonNull android.content.Context);
method @Nullable public static android.net.Uri getRedactedUri(@NonNull android.content.ContentResolver, @NonNull android.net.Uri);
method @NonNull public static java.util.List<android.net.Uri> getRedactedUri(@NonNull android.content.ContentResolver, @NonNull java.util.List<android.net.Uri>);
diff --git a/apex/framework/java/android/provider/MediaStore.java b/apex/framework/java/android/provider/MediaStore.java
index 92d2187..2e3bf4f 100644
--- a/apex/framework/java/android/provider/MediaStore.java
+++ b/apex/framework/java/android/provider/MediaStore.java
@@ -218,6 +218,8 @@
public static final String GET_REDACTED_MEDIA_URI_LIST_CALL = "get_redacted_media_uri_list";
/** {@hide} */
public static final String EXTRA_URI_LIST = "uri_list";
+ /** {@hide} */
+ public static final String QUERY_ARG_REDACTED_URI = "android:query-arg-redacted-uri";
/** {@hide} */
public static final String EXTRA_URI = "uri";
@@ -275,7 +277,15 @@
/** {@hide} */
public static final String PARAM_LIMIT = "limit";
- private static final int DEFAULT_USER_ID = UserHandle.myUserId();
+ /** {@hide} */
+ private static final int MY_USER_ID = UserHandle.myUserId();
+ /** {@hide} */
+ public static final int MY_UID = android.os.Process.myUid();
+ // Stolen from: UserHandle#getUserId
+ /** {@hide} */
+ public static final int PER_USER_RANGE = 100000;
+
+ private static final int PICK_IMAGES_MAX_LIMIT = 100;
/**
* Activity Action: Launch a music player.
@@ -668,15 +678,13 @@
* <p>
* If the caller needs multiple returned items (or caller wants to allow
* multiple selection), then it can specify
- * {@link Intent#EXTRA_ALLOW_MULTIPLE} to indicate this. When multiple
- * selection is enabled, callers can also constrain number of selection
- * {@link MediaStore#EXTRA_PICK_IMAGES_MAX}.
+ * {@link MediaStore#EXTRA_PICK_IMAGES_MAX} to indicate this.
* <p>
- * When the caller requests {@link Intent#EXTRA_ALLOW_MULTIPLE}, and
- * doesn't request {@link MediaStore#EXTRA_PICK_IMAGES_MAX} or value of
- * {@link MediaStore#EXTRA_PICK_IMAGES_MAX} exceeds the default maximum,
- * then number of selection will be restricted to a default maximum of 100
- * items.
+ * When the caller requests multiple selection, the value of
+ * {@link MediaStore#EXTRA_PICK_IMAGES_MAX} must be a positive integer
+ * greater than 1 and less than or equal to
+ * {@link MediaStore#getPickImagesMaxLimit}, otherwise
+ * {@link Activity#RESULT_CANCELED} is returned.
* <p>
* Output: MediaStore content URI(s) of the item(s) that was picked.
*/
@@ -684,15 +692,28 @@
public static final String ACTION_PICK_IMAGES = "android.provider.action.PICK_IMAGES";
/**
- * The name of an optional intent-extra used to constrain maximum number of
- * items that can be returned by {@link MediaStore#ACTION_PICK_IMAGES},
- * action may still return nothing (0 items) if the user chooses to cancel.
- * The value of this intext-extra should be a non-negative integer greater
- * than or equal to 1, the value is ignored otherwise.
+ * The name of an optional intent-extra used to allow multiple selection of
+ * items and constrain maximum number of items that can be returned by
+ * {@link MediaStore#ACTION_PICK_IMAGES}, action may still return nothing
+ * (0 items) if the user chooses to cancel.
+ * <p>
+ * The value of this intent-extra should be a positive integer greater
+ * than 1 and less than or equal to
+ * {@link MediaStore#getPickImagesMaxLimit}, otherwise
+ * {@link Activity#RESULT_CANCELED} is returned.
*/
public final static String EXTRA_PICK_IMAGES_MAX = "android.provider.extra.PICK_IMAGES_MAX";
/**
+ * The maximum limit for the number of items that can be selected using
+ * {@link MediaStore#ACTION_PICK_IMAGES} when launched in multiple selection mode.
+ * This can be used as a constant value for {@link MediaStore#EXTRA_PICK_IMAGES_MAX}.
+ */
+ public static int getPickImagesMaxLimit() {
+ return PICK_IMAGES_MAX_LIMIT;
+ }
+
+ /**
* Specify that the caller wants to receive the original media format without transcoding.
*
* <b>Caution: using this flag can cause app
@@ -4317,7 +4338,7 @@
private static int getUserIdFromUri(Uri uri) {
final String userId = uri.getUserInfo();
- return userId == null ? DEFAULT_USER_ID : Integer.parseInt(userId);
+ return userId == null ? MY_USER_ID : Integer.parseInt(userId);
}
private static Uri maybeAddUserId(@NonNull Uri uri, String userId) {
diff --git a/jni/FuseDaemon.cpp b/jni/FuseDaemon.cpp
index 53f86cd..6f8bbde 100755
--- a/jni/FuseDaemon.cpp
+++ b/jni/FuseDaemon.cpp
@@ -120,6 +120,7 @@
static constexpr char TRANSFORM_SYNTHETIC_DIR[] = "synthetic";
static constexpr char TRANSFORM_TRANSCODE_DIR[] = "transcode";
+static constexpr char PRIMARY_VOLUME_PREFIX[] = "/storage/emulated";
/*
* In order to avoid double caching with fuse, call fadvise on the file handles
@@ -261,7 +262,7 @@
inline bool IsRoot(const node* node) const { return node == root; }
inline string GetEffectiveRootPath() {
- if (android::base::StartsWith(path, "/storage/emulated")) {
+ if (android::base::StartsWith(path, PRIMARY_VOLUME_PREFIX)) {
return path + "/" + MY_USER_ID_STRING;
}
return path;
@@ -335,6 +336,12 @@
const std::vector<string> supported_transcoding_relative_paths;
};
+struct OpenInfo {
+ int flags;
+ bool for_write;
+ bool direct_io;
+};
+
enum class FuseOp { lookup, readdir, mknod, mkdir, create };
static inline string get_name(node* n) {
@@ -346,7 +353,7 @@
return "?";
}
-static inline __u64 ptr_to_id(void* ptr) {
+static inline __u64 ptr_to_id(const void* ptr) {
return (__u64)(uintptr_t) ptr;
}
@@ -508,13 +515,13 @@
if (!file_lookup_result) {
// Fail lookup if we can't fetch FileLookupResult for path
LOG(WARNING) << "Failed to fetch FileLookupResult for " << path;
- *error_code = ENOENT;
+ *error_code = EFAULT;
return nullptr;
}
const string& io_path = file_lookup_result->io_path;
- // Update size with io_path size if io_path is not same as path
- if (!io_path.empty() && (io_path != path) && (lstat(io_path.c_str(), &e->attr) < 0)) {
+ // Update size with io_path iff there's an io_path
+ if (!io_path.empty() && (lstat(io_path.c_str(), &e->attr) < 0)) {
*error_code = errno;
return nullptr;
}
@@ -592,10 +599,6 @@
return node;
}
-static inline bool is_requesting_write(int flags) {
- return flags & (O_WRONLY | O_RDWR);
-}
-
namespace mediaprovider {
namespace fuse {
@@ -666,7 +669,7 @@
return true;
}
- if (path == "/storage/emulated") {
+ if (path == PRIMARY_VOLUME_PREFIX) {
// Apps should never refer to /storage/emulated - they should be using the user-spcific
// subdirs, eg /storage/emulated/0
return false;
@@ -680,7 +683,7 @@
if (pkg == ".nomedia") {
return true;
}
- if (android::base::StartsWith(path, "/storage/emulated")) {
+ if (android::base::StartsWith(path, PRIMARY_VOLUME_PREFIX)) {
// Emulated storage bind-mounts app-private data directories, and so these
// should not be accessible through FUSE anyway.
LOG(WARNING) << "Rejected access to app-private dir on FUSE: " << path
@@ -1174,7 +1177,7 @@
static handle* create_handle_for_node(struct fuse* fuse, const string& path, int fd, uid_t uid,
uid_t transforms_uid, node* node, const RedactionInfo* ri,
- int* keep_cache) {
+ const bool open_info_direct_io, int* keep_cache) {
std::lock_guard<std::recursive_mutex> guard(fuse->lock);
bool redaction_needed = ri->isRedactionNeeded();
@@ -1195,7 +1198,7 @@
// arbitrary bytes the first time around. However, if we ensure that transforms are
// completed, then it's safe to use passthrough. Additionally, transcoded nodes never
// require redaction so (2) implies (1)
- handle = new struct handle(fd, ri, true /* cached */,
+ handle = new struct handle(fd, ri, !open_info_direct_io /* cached */,
!redaction_needed && transforms_complete /* passthrough */, uid,
transforms_uid);
} else {
@@ -1216,7 +1219,8 @@
bool is_redaction_change =
(redaction_needed && !has_redacted) || (!redaction_needed && has_redacted);
bool is_cached_file_open = node->HasCachedHandle();
- bool direct_io = (is_cached_file_open && is_redaction_change) || is_file_locked(fd, path);
+ bool direct_io = open_info_direct_io || (is_cached_file_open && is_redaction_change) ||
+ is_file_locked(fd, path);
if (!is_cached_file_open && is_redaction_change) {
node->SetRedactedCache(redaction_needed);
@@ -1233,7 +1237,7 @@
return handle;
}
-bool do_passthrough_enable(fuse_req_t req, struct fuse_file_info* fi, unsigned int fd) {
+static bool do_passthrough_enable(fuse_req_t req, struct fuse_file_info* fi, unsigned int fd) {
int passthrough_fh = fuse_passthrough_enable(req, fd);
if (passthrough_fh <= 0) {
@@ -1244,6 +1248,45 @@
return true;
}
+static OpenInfo parse_open_flags(const string& path, const int in_flags) {
+ const bool for_write = in_flags & (O_WRONLY | O_RDWR);
+ int out_flags = in_flags;
+ bool direct_io = false;
+
+ if (in_flags & O_DIRECT) {
+ // Set direct IO on the FUSE fs file
+ direct_io = true;
+
+ if (android::base::StartsWith(path, PRIMARY_VOLUME_PREFIX)) {
+ // Remove O_DIRECT because there are strict alignment requirements for direct IO and
+ // there were some historical bugs affecting encrypted block devices.
+ // Hence, this is only supported on public volumes.
+ out_flags &= ~O_DIRECT;
+ }
+ }
+ if (in_flags & O_WRONLY) {
+ // Replace O_WRONLY with O_RDWR because even if the FUSE fd is opened write-only, the FUSE
+ // driver might issue reads on the lower fs ith the writeback cache enabled
+ out_flags &= ~O_WRONLY;
+ out_flags |= O_RDWR;
+ }
+ if (in_flags & O_APPEND) {
+ // Remove O_APPEND because passing it to the lower fs can lead to file corruption when
+ // multiple FUSE threads race themselves reading. With writeback cache enabled, the FUSE
+ // driver already handles the O_APPEND
+ out_flags &= ~O_APPEND;
+ }
+
+ return {.flags = out_flags, .for_write = for_write, .direct_io = direct_io};
+}
+
+static void fill_fuse_file_info(const handle* handle, const OpenInfo* open_info,
+ const int keep_cache, struct fuse_file_info* fi) {
+ fi->fh = ptr_to_id(handle);
+ fi->keep_cache = keep_cache;
+ fi->direct_io = !handle->cached;
+}
+
static void pf_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi) {
ATRACE_CALL();
struct fuse* fuse = get_fuse(req);
@@ -1260,25 +1303,21 @@
return;
}
- bool for_write = is_requesting_write(fi->flags);
+ const OpenInfo open_info = parse_open_flags(io_path, fi->flags);
- if (for_write && node->GetTransforms()) {
+ if (open_info.for_write && node->GetTransforms()) {
TRACE_NODE(node, req) << "write with transforms";
} else {
- TRACE_NODE(node, req) << (for_write ? "write" : "read");
- }
-
- if (fi->flags & O_DIRECT) {
- fi->flags &= ~O_DIRECT;
- fi->direct_io = true;
+ TRACE_NODE(node, req) << (open_info.for_write ? "write" : "read");
}
// Force permission check with the build path because the MediaProvider database might not be
// aware of the io_path
// We don't redact if the caller was granted write permission for this file
std::unique_ptr<FileOpenResult> result = fuse->mp->OnFileOpen(
- build_path, io_path, ctx->uid, ctx->pid, node->GetTransformsReason(), for_write,
- !for_write /* redact */, true /* log_transforms_metrics */);
+ build_path, io_path, ctx->uid, ctx->pid, node->GetTransformsReason(),
+ open_info.for_write, !open_info.for_write /* redact */,
+ true /* log_transforms_metrics */);
if (!result) {
fuse_reply_err(req, EFAULT);
return;
@@ -1289,41 +1328,32 @@
return;
}
- // With the writeback cache enabled, FUSE may generate READ requests even for files that
- // were opened O_WRONLY; so make sure we open it O_RDWR instead.
- int open_flags = fi->flags;
- if (open_flags & O_WRONLY) {
- open_flags &= ~O_WRONLY;
- open_flags |= O_RDWR;
- }
-
- if (open_flags & O_APPEND) {
- open_flags &= ~O_APPEND;
- }
-
- const int fd = open(io_path.c_str(), open_flags);
- if (fd < 0) {
- fuse_reply_err(req, errno);
- return;
+ int fd = -1;
+ if (result->fd >= 0) {
+ fd = result->fd;
+ TRACE_NODE(node, req) << "opened in Java";
+ } else {
+ fd = open(io_path.c_str(), open_info.flags);
+ if (fd < 0) {
+ fuse_reply_err(req, errno);
+ return;
+ }
}
int keep_cache = 1;
- handle* h = create_handle_for_node(fuse, io_path, fd, result->uid, result->transforms_uid, node,
- result->redaction_info.release(), &keep_cache);
- fi->fh = ptr_to_id(h);
- fi->keep_cache = keep_cache;
- fi->direct_io = !h->cached;
+ const handle* h = create_handle_for_node(fuse, io_path, fd, result->uid, result->transforms_uid,
+ node, result->redaction_info.release(),
+ open_info.direct_io, &keep_cache);
+ fill_fuse_file_info(h, &open_info, keep_cache, fi);
// TODO(b/173190192) ensuring that h->cached must be enabled in order to
// user FUSE passthrough is a conservative rule and might be dropped as
// soon as demonstrated its correctness.
- if (h->passthrough) {
- if (!do_passthrough_enable(req, fi, fd)) {
- // TODO: Should we crash here so we can find errors easily?
- PLOG(ERROR) << "Passthrough OPEN failed for " << io_path;
- fuse_reply_err(req, EFAULT);
- return;
- }
+ if (h->passthrough && !do_passthrough_enable(req, fi, fd)) {
+ // TODO: Should we crash here so we can find errors easily?
+ PLOG(ERROR) << "Passthrough OPEN failed for " << io_path;
+ fuse_reply_err(req, EFAULT);
+ return;
}
fuse_reply_open(req, fi);
@@ -1749,7 +1779,7 @@
return;
}
const string path = node->BuildPath();
- if (path != "/storage/emulated" && !is_app_accessible_path(fuse->mp, path, req->ctx.uid)) {
+ if (path != PRIMARY_VOLUME_PREFIX && !is_app_accessible_path(fuse->mp, path, req->ctx.uid)) {
fuse_reply_err(req, ENOENT);
return;
}
@@ -1773,7 +1803,7 @@
bool for_write = mask & W_OK;
bool is_directory = S_ISDIR(stat.st_mode);
if (is_directory) {
- if (path == "/storage/emulated" && mask == X_OK) {
+ if (path == PRIMARY_VOLUME_PREFIX && mask == X_OK) {
// Special case for this path: apps should be allowed to enter it,
// but not list directory contents (which would be user numbers).
int res = access(path.c_str(), X_OK);
@@ -1823,26 +1853,16 @@
const string child_path = parent_path + "/" + name;
+ const OpenInfo open_info = parse_open_flags(child_path, fi->flags);
+
int mp_return_code = fuse->mp->InsertFile(child_path.c_str(), req->ctx.uid);
if (mp_return_code) {
fuse_reply_err(req, mp_return_code);
return;
}
- // With the writeback cache enabled, FUSE may generate READ requests even for files that
- // were opened O_WRONLY; so make sure we open it O_RDWR instead.
- int open_flags = fi->flags;
- if (open_flags & O_WRONLY) {
- open_flags &= ~O_WRONLY;
- open_flags |= O_RDWR;
- }
-
- if (open_flags & O_APPEND) {
- open_flags &= ~O_APPEND;
- }
-
mode = (mode & (~0777)) | 0664;
- int fd = open(child_path.c_str(), open_flags, mode);
+ int fd = open(child_path.c_str(), open_info.flags, mode);
if (fd < 0) {
int error_code = errno;
// We've already inserted the file into the MP database before the
@@ -1871,21 +1891,18 @@
// to the file before all the EXIF content is written. We could special case reads before the
// first close after a file has just been created.
int keep_cache = 1;
- handle* h = create_handle_for_node(fuse, child_path, fd, req->ctx.uid, 0 /* transforms_uid */,
- node, new RedactionInfo(), &keep_cache);
- fi->fh = ptr_to_id(h);
- fi->keep_cache = keep_cache;
- fi->direct_io = !h->cached;
+ const handle* h =
+ create_handle_for_node(fuse, child_path, fd, req->ctx.uid, 0 /* transforms_uid */, node,
+ new RedactionInfo(), open_info.direct_io, &keep_cache);
+ fill_fuse_file_info(h, &open_info, keep_cache, fi);
// TODO(b/173190192) ensuring that h->cached must be enabled in order to
// user FUSE passthrough is a conservative rule and might be dropped as
// soon as demonstrated its correctness.
- if (h->passthrough) {
- if (!do_passthrough_enable(req, fi, fd)) {
- PLOG(ERROR) << "Passthrough CREATE failed for " << child_path;
- fuse_reply_err(req, EFAULT);
- return;
- }
+ if (h->passthrough && !do_passthrough_enable(req, fi, fd)) {
+ PLOG(ERROR) << "Passthrough CREATE failed for " << child_path;
+ fuse_reply_err(req, EFAULT);
+ return;
}
fuse_reply_create(req, &e, fi);
diff --git a/jni/MediaProviderWrapper.cpp b/jni/MediaProviderWrapper.cpp
index 9f8a759..01411f5 100644
--- a/jni/MediaProviderWrapper.cpp
+++ b/jni/MediaProviderWrapper.cpp
@@ -289,6 +289,7 @@
fid_file_open_transforms_uid_ = CacheField(env, file_open_result_class_, "transformsUid", "I");
fid_file_open_redaction_ranges_ =
CacheField(env, file_open_result_class_, "redactionRanges", "[J");
+ fid_file_open_fd_ = CacheField(env, file_open_result_class_, "nativeFd", "I");
}
MediaProviderWrapper::~MediaProviderWrapper() {
@@ -323,7 +324,8 @@
bool log_transforms_metrics) {
JNIEnv* env = MaybeAttachCurrentThread();
if (shouldBypassMediaProvider(uid)) {
- return std::make_unique<FileOpenResult>(0, uid, 0 /* transforms_uid */, new RedactionInfo());
+ return std::make_unique<FileOpenResult>(0, uid, /* transforms_uid */ 0, /* nativeFd */ -1,
+ new RedactionInfo());
}
ScopedLocalRef<jstring> j_path(env, env->NewStringUTF(path.c_str()));
@@ -337,10 +339,11 @@
return nullptr;
}
- int status = env->GetIntField(j_res_file_open_object.get(), fid_file_open_status_);
- int original_uid = env->GetIntField(j_res_file_open_object.get(), fid_file_open_uid_);
- int transforms_uid =
+ const int status = env->GetIntField(j_res_file_open_object.get(), fid_file_open_status_);
+ const int original_uid = env->GetIntField(j_res_file_open_object.get(), fid_file_open_uid_);
+ const int transforms_uid =
env->GetIntField(j_res_file_open_object.get(), fid_file_open_transforms_uid_);
+ const int fd = env->GetIntField(j_res_file_open_object.get(), fid_file_open_fd_);
if (redact) {
ScopedLocalRef<jlongArray> redaction_ranges_local_ref(
@@ -358,9 +361,10 @@
// No ranges to redact
ri = std::make_unique<RedactionInfo>();
}
- return std::make_unique<FileOpenResult>(status, original_uid, transforms_uid, ri.release());
+ return std::make_unique<FileOpenResult>(status, original_uid, transforms_uid, fd,
+ ri.release());
} else {
- return std::make_unique<FileOpenResult>(status, original_uid, transforms_uid,
+ return std::make_unique<FileOpenResult>(status, original_uid, transforms_uid, fd,
new RedactionInfo());
}
}
diff --git a/jni/MediaProviderWrapper.h b/jni/MediaProviderWrapper.h
index 2ad1769..bc7c656 100644
--- a/jni/MediaProviderWrapper.h
+++ b/jni/MediaProviderWrapper.h
@@ -38,13 +38,18 @@
/** Represents file open result from MediaProvider */
struct FileOpenResult {
- FileOpenResult(const int status, const int uid, uid_t transforms_uid,
+ FileOpenResult(const int status, const int uid, const uid_t transforms_uid, const int fd,
const RedactionInfo* redaction_info)
- : status(status), uid(uid), transforms_uid(transforms_uid), redaction_info(redaction_info) {}
+ : status(status),
+ uid(uid),
+ transforms_uid(transforms_uid),
+ fd(fd),
+ redaction_info(redaction_info) {}
const int status;
const int uid;
const uid_t transforms_uid;
+ const int fd;
std::unique_ptr<const RedactionInfo> redaction_info;
};
@@ -281,6 +286,7 @@
jfieldID fid_file_open_uid_;
jfieldID fid_file_open_transforms_uid_;
jfieldID fid_file_open_redaction_ranges_;
+ jfieldID fid_file_open_fd_;
/**
* Auxiliary for caching MediaProvider methods.
diff --git a/res/layout/fragment_picker_tab.xml b/res/layout/fragment_picker_tab.xml
index 5fb1311..a62f21f 100644
--- a/res/layout/fragment_picker_tab.xml
+++ b/res/layout/fragment_picker_tab.xml
@@ -21,7 +21,7 @@
android:layout_height="match_parent">
<com.android.providers.media.photopicker.ui.AutoFitRecyclerView
- android:id="@+id/photo_list"
+ android:id="@+id/picker_tab_recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index a56d8d5..64050a6 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -60,11 +60,13 @@
<dimen name="preview_buttons_margin_horizontal">16dp</dimen>
<dimen name="preview_buttons_margin_bottom">10dp</dimen>
<dimen name="preview_deselect_padding_start">2dp</dimen>
+ <dimen name="preview_viewpager_margin">20dp</dimen>
<!-- PhotoPicker Preview text -->
<dimen name="preview_add_text_size">14sp</dimen>
<dimen name="preview_deselect_text_size">16sp</dimen>
<dimen name="preview_toolbar_scrim_height">96dp</dimen>
<dimen name="preview_deselect_scrim_height">240dp</dimen>
+
<!-- PhotoPicker Work Profile -->
<dimen name="picker_profile_button_margin_bottom">32dp</dimen>
<dimen name="picker_profile_dialog_radius">8dp</dimen>
diff --git a/src/com/android/providers/media/FileLookupResult.java b/src/com/android/providers/media/FileLookupResult.java
index f0e6bfb..4ac0c0e 100644
--- a/src/com/android/providers/media/FileLookupResult.java
+++ b/src/com/android/providers/media/FileLookupResult.java
@@ -28,6 +28,11 @@
public final boolean transformsSupported;
public final String ioPath;
+ public FileLookupResult(int transforms, int uid, String ioPath) {
+ this (transforms, /* transformsReason */ 0, uid, /* transformsComplete */ true,
+ /* transformsSupported */ transforms == 0 ? false : true, ioPath);
+ }
+
public FileLookupResult(int transforms, int transformsReason, int uid,
boolean transformsComplete, boolean transformsSupported, String ioPath) {
this.transforms = transforms;
diff --git a/src/com/android/providers/media/FileOpenResult.java b/src/com/android/providers/media/FileOpenResult.java
index 1052f98..ff6c820 100644
--- a/src/com/android/providers/media/FileOpenResult.java
+++ b/src/com/android/providers/media/FileOpenResult.java
@@ -23,12 +23,19 @@
public final int status;
public final int uid;
public final int transformsUid;
+ public final int nativeFd;
public final long[] redactionRanges;
public FileOpenResult(int status, int uid, int transformsUid, long[] redactionRanges) {
+ this(status, uid, transformsUid, /* nativeFd */ -1, redactionRanges);
+ }
+
+ public FileOpenResult(int status, int uid, int transformsUid, int nativeFd,
+ long[] redactionRanges) {
this.status = status;
this.uid = uid;
this.transformsUid = transformsUid;
+ this.nativeFd = nativeFd;
this.redactionRanges = redactionRanges;
}
}
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 4c3e5f6..88e73e6 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -29,10 +29,13 @@
import static android.provider.MediaStore.MATCH_EXCLUDE;
import static android.provider.MediaStore.MATCH_INCLUDE;
import static android.provider.MediaStore.MATCH_ONLY;
+import static android.provider.MediaStore.MY_UID;
+import static android.provider.MediaStore.PER_USER_RANGE;
import static android.provider.MediaStore.QUERY_ARG_DEFER_SCAN;
import static android.provider.MediaStore.QUERY_ARG_MATCH_FAVORITE;
import static android.provider.MediaStore.QUERY_ARG_MATCH_PENDING;
import static android.provider.MediaStore.QUERY_ARG_MATCH_TRASHED;
+import static android.provider.MediaStore.QUERY_ARG_REDACTED_URI;
import static android.provider.MediaStore.QUERY_ARG_RELATED_URI;
import static android.provider.MediaStore.getVolumeName;
import static android.system.OsConstants.F_GETFL;
@@ -63,6 +66,8 @@
import static com.android.providers.media.util.DatabaseUtils.bindList;
import static com.android.providers.media.util.FileUtils.DEFAULT_FOLDER_NAMES;
import static com.android.providers.media.util.FileUtils.PATTERN_PENDING_FILEPATH_FOR_SQL;
+import static com.android.providers.media.util.FileUtils.buildPath;
+import static com.android.providers.media.util.FileUtils.buildPrimaryVolumeFile;
import static com.android.providers.media.util.FileUtils.extractDisplayName;
import static com.android.providers.media.util.FileUtils.extractFileExtension;
import static com.android.providers.media.util.FileUtils.extractFileName;
@@ -79,6 +84,15 @@
import static com.android.providers.media.util.FileUtils.isExternalMediaDirectory;
import static com.android.providers.media.util.FileUtils.isObbOrChildPath;
import static com.android.providers.media.util.FileUtils.sanitizePath;
+import static com.android.providers.media.util.SyntheticPathUtils.REDACTED_URI_ID_PREFIX;
+import static com.android.providers.media.util.SyntheticPathUtils.REDACTED_URI_ID_SIZE;
+import static com.android.providers.media.util.SyntheticPathUtils.createSparseFile;
+import static com.android.providers.media.util.SyntheticPathUtils.extractSyntheticRelativePathSegements;
+import static com.android.providers.media.util.SyntheticPathUtils.getPickerRelativePath;
+import static com.android.providers.media.util.SyntheticPathUtils.getRedactedRelativePath;
+import static com.android.providers.media.util.SyntheticPathUtils.isPickerPath;
+import static com.android.providers.media.util.SyntheticPathUtils.isRedactedPath;
+import static com.android.providers.media.util.SyntheticPathUtils.isSyntheticPath;
import static com.android.providers.media.util.Logging.LOGV;
import static com.android.providers.media.util.Logging.TAG;
@@ -152,6 +166,7 @@
import android.os.storage.StorageVolume;
import android.preference.PreferenceManager;
import android.provider.BaseColumns;
+import android.provider.CloudMediaProviderContract;
import android.provider.Column;
import android.provider.DeviceConfig;
import android.provider.DeviceConfig.OnPropertiesChangedListener;
@@ -274,6 +289,9 @@
/** File access by uid is a synthetic path corresponding to a redacted URI */
private static final int FLAG_TRANSFORM_REDACTION = 1 << 1;
+ /** File access by uid is a synthetic path corresponding to a picker URI */
+ private static final int FLAG_TRANSFORM_PICKER = 1 << 2;
+
/**
* These directory names aren't declared in Environment as final variables, and so we need to
* have the same values in separate final variables in order to have them considered constant
@@ -298,11 +316,6 @@
private static final String DIRECTORY_MEDIA = "media";
private static final String DIRECTORY_THUMBNAILS = ".thumbnails";
private static final List<String> PRIVATE_SUBDIRECTORIES_ANDROID = Arrays.asList("data", "obb");
- private static final String REDACTED_URI_ID_PREFIX = "RUID";
- private static final String TRANSFORMS_SYNTHETIC_DIR = ".transforms/synthetic";
- private static final String REDACTED_URI_DIR = TRANSFORMS_SYNTHETIC_DIR + "/redacted";
- public static final int REDACTED_URI_ID_SIZE = 36;
- private static final String QUERY_ARG_REDACTED_URI = "android:query-arg-redacted-uri";
/**
* Hard-coded filename where the current value of
@@ -359,10 +372,6 @@
@EnabledAfter(targetSdkVersion = Build.VERSION_CODES.R)
static final long ENABLE_INCLUDE_ALL_VOLUMES = 182734110L;
- // Stolen from: UserHandle#getUserId
- private static final int PER_USER_RANGE = 100000;
- private static final int MY_UID = android.os.Process.myUid();
-
/**
* Set of {@link Cursor} columns that refer to raw filesystem paths.
*/
@@ -505,7 +514,7 @@
LocalCallingIdentity identity = mCachedCallingIdentityForFuse.get(uid);
if (identity == null) {
identity = LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid);
- if (uid / PER_USER_RANGE == sUserId) {
+ if (uidToUserId(uid) == sUserId) {
mCachedCallingIdentityForFuse.put(uid, identity);
} else {
// In some app cloning designs, MediaProvider user 0 may
@@ -952,8 +961,8 @@
mTranscodeHelper = new TranscodeHelperNoOp();
}
- // Create dir for redacted URI's path.
- new File("/storage/emulated/" + UserHandle.myUserId(), REDACTED_URI_DIR).mkdirs();
+ // Create dir for redacted and picker URI paths.
+ buildPrimaryVolumeFile(uidToUserId(MY_UID), getRedactedRelativePath()).mkdirs();
final IntentFilter packageFilter = new IntentFilter();
packageFilter.setPriority(10);
@@ -1432,7 +1441,7 @@
*/
@Keep
public boolean shouldAllowLookupForFuse(int uid, int pathUserId) {
- int callingUserId = uid / PER_USER_RANGE;
+ int callingUserId = uidToUserId(uid);
if (!isCrossUserEnabled()) {
Log.d(TAG, "CrossUser not enabled. Users: " + callingUserId + " and " + pathUserId);
return false;
@@ -1535,58 +1544,52 @@
@Keep
public FileLookupResult onFileLookupForFuse(String path, int uid, int tid) {
uid = getBinderUidForFuse(uid, tid);
- if (isSyntheticFilePathForRedactedUri(path, uid)) {
- return getFileLookupResultsForRedactedUriPath(uid, path);
+ final int userId = uidToUserId(uid);
+
+ if (isSyntheticPath(path, userId)) {
+ if (isRedactedPath(path, userId)) {
+ return handleRedactedFileLookup(uid, path);
+ } else if (isPickerPath(path, userId)) {
+ return handlePickerFileLookup(userId, uid, path);
+ }
+
+ throw new IllegalStateException("Unexpected synthetic path: " + path);
}
- String ioPath = "";
- boolean transformsComplete = true;
- boolean transformsSupported = mTranscodeHelper.supportsTranscode(path);
- int transforms = 0;
- int transformsReason = 0;
-
- if (transformsSupported) {
- PendingOpenInfo info = null;
- synchronized (mPendingOpenInfo) {
- info = mPendingOpenInfo.get(tid);
- }
-
- if (info != null && info.uid == uid) {
- transformsReason = info.transcodeReason;
- } else {
- transformsReason = mTranscodeHelper.shouldTranscode(path, uid, null /* bundle */);
- }
-
- if (transformsReason > 0) {
- ioPath = mTranscodeHelper.getIoPath(path, uid);
- transformsComplete = mTranscodeHelper.isTranscodeFileCached(path, ioPath);
- transforms = FLAG_TRANSFORM_TRANSCODING;
- }
+ if (mTranscodeHelper.supportsTranscode(path)) {
+ return handleTranscodedFileLookup(path, uid, tid);
}
- return new FileLookupResult(transforms, transformsReason, uid, transformsComplete,
- transformsSupported, ioPath);
+ return new FileLookupResult(/* transforms */ 0, uid, /* ioPath */ "");
}
- private boolean isSyntheticFilePathForRedactedUri(String path, int uid) {
- if (path == null) return false;
+ private FileLookupResult handleTranscodedFileLookup(String path, int uid, int tid) {
+ final int transformsReason;
+ final PendingOpenInfo info;
- final String transformsSyntheticDir = getStorageRootPathForUid(uid) + "/"
- + REDACTED_URI_DIR;
- final String fileName = extractFileName(path);
- return fileName != null && path.toLowerCase(Locale.ROOT).startsWith(
- transformsSyntheticDir.toLowerCase(Locale.ROOT)) && fileName.startsWith(
- REDACTED_URI_ID_PREFIX) && fileName.length() == REDACTED_URI_ID_SIZE;
+ synchronized (mPendingOpenInfo) {
+ info = mPendingOpenInfo.get(tid);
+ }
+
+ if (info != null && info.uid == uid) {
+ transformsReason = info.transcodeReason;
+ } else {
+ transformsReason = mTranscodeHelper.shouldTranscode(path, uid, null /* bundle */);
+ }
+
+ if (transformsReason > 0) {
+ final String ioPath = mTranscodeHelper.prepareIoPath(path, uid);
+ final boolean transformsComplete = mTranscodeHelper.isTranscodeFileCached(path, ioPath);
+
+ return new FileLookupResult(FLAG_TRANSFORM_TRANSCODING, transformsReason, uid,
+ transformsComplete, /* transformsSupported */ true, ioPath);
+ }
+
+ return new FileLookupResult(/* transforms */ 0, transformsReason, uid,
+ /* transformsComplete */ true, /* transformsSupported */ true, "");
}
- private boolean isSyntheticDirPath(String path, int uid) {
- final String transformsSyntheticDir = getStorageRootPathForUid(uid) + "/"
- + TRANSFORMS_SYNTHETIC_DIR;
- return path != null && path.toLowerCase(Locale.ROOT).startsWith(
- transformsSyntheticDir.toLowerCase(Locale.ROOT));
- }
-
- private FileLookupResult getFileLookupResultsForRedactedUriPath(int uid, @NonNull String path) {
+ private FileLookupResult handleRedactedFileLookup(int uid, @NonNull String path) {
final LocalCallingIdentity token = clearLocalCallingIdentity();
final String fileName = extractFileName(path);
@@ -1601,17 +1604,84 @@
(db) -> db.query("files", new String[]{MediaColumns.DATA},
FileColumns.REDACTED_URI_ID + "=?", new String[]{fileName}, null, null,
null))) {
- if (!c.moveToFirst()) {
- return new FileLookupResult(FLAG_TRANSFORM_REDACTION, 0, uid, false, true, null);
+ if (c.moveToFirst()) {
+ return new FileLookupResult(FLAG_TRANSFORM_REDACTION, uid, c.getString(0));
}
- return new FileLookupResult(FLAG_TRANSFORM_REDACTION, 0, uid, true, true,
- c.getString(0));
+ throw new IllegalStateException("Failed to fetch synthetic redacted path: " + path);
} finally {
restoreLocalCallingIdentity(token);
}
}
+ private FileLookupResult handlePickerFileLookup(int userId, int uid, @NonNull String path) {
+ final File file = new File(path);
+ final List<String> syntheticRelativePathSegments =
+ extractSyntheticRelativePathSegements(path, userId);
+ final int segmentCount = syntheticRelativePathSegments.size();
+
+ if (segmentCount < 1 || segmentCount > 4) {
+ throw new IllegalStateException("Unexpected synthetic picker path: " + file);
+ }
+
+ final String lastSegment = syntheticRelativePathSegments.get(segmentCount - 1);
+
+ boolean result = false;
+ switch (segmentCount) {
+ case 1:
+ // .../picker
+ if (lastSegment.equals("picker")) {
+ result = file.exists() || file.mkdir();
+ }
+ break;
+ case 2:
+ // .../picker/<authority>
+ result = preparePickerAuthorityPathSegment(file, lastSegment, uid);
+ break;
+ case 3:
+ // .../picker/<authority>/media
+ if (lastSegment.equals("media")) {
+ result = file.exists() || file.mkdir();
+ }
+ break;
+ case 4:
+ // .../picker/<authority>/media/<media-id.extension>
+ final String authority = syntheticRelativePathSegments.get(1);
+ result = preparePickerMediaIdPathSegment(file, authority, lastSegment);
+ break;
+ }
+
+ if (result) {
+ return new FileLookupResult(FLAG_TRANSFORM_PICKER, uid, path);
+ }
+ throw new IllegalStateException("Failed to prepare synthetic picker path: " + file);
+ }
+
+ private boolean preparePickerAuthorityPathSegment(File file, String authority, int uid) {
+ if (mPickerSyncController.isProviderEnabled(authority)) {
+ return file.mkdir();
+ }
+
+ return false;
+ }
+
+ private boolean preparePickerMediaIdPathSegment(File file, String authority, String fileName) {
+ final String mediaId = extractFileName(fileName);
+
+ try (Cursor cursor = mPickerDbFacade.queryMediaId(authority, mediaId)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ final int sizeBytesIdx = cursor.getColumnIndex(
+ CloudMediaProviderContract.MediaColumns.SIZE_BYTES);
+
+ if (sizeBytesIdx != -1) {
+ return createSparseFile(file, cursor.getLong(sizeBytesIdx));
+ }
+ }
+ }
+
+ return false;
+ }
+
public int getBinderUidForFuse(int uid, int tid) {
if (uid != MY_UID) {
return uid;
@@ -1626,6 +1696,10 @@
}
}
+ private static int uidToUserId(int uid) {
+ return uid / PER_USER_RANGE;
+ }
+
/**
* Returns true if the app denoted by the given {@code uid} and {@code packageName} is allowed
* to clear other apps' cache directories.
@@ -2999,13 +3073,13 @@
String ext = getFileExtensionFromCursor(c, columnNames);
ext = ext == null ? "" : "." + ext;
final String displayName = redactedUriId + ext;
- final String data = getPathForRedactedUriId(displayName);
-
+ final String data = buildPrimaryVolumeFile(uidToUserId(Binder.getCallingUid()),
+ getRedactedRelativePath(), displayName).getAbsolutePath();
updateRow(columnNames, MediaColumns._ID, row, redactedUriId);
updateRow(columnNames, MediaColumns.DISPLAY_NAME, row, displayName);
- updateRow(columnNames, MediaColumns.RELATIVE_PATH, row, REDACTED_URI_DIR);
- updateRow(columnNames, MediaColumns.BUCKET_DISPLAY_NAME, row, REDACTED_URI_DIR);
+ updateRow(columnNames, MediaColumns.RELATIVE_PATH, row, getRedactedRelativePath());
+ updateRow(columnNames, MediaColumns.BUCKET_DISPLAY_NAME, row, getRedactedRelativePath());
updateRow(columnNames, MediaColumns.DATA, row, data);
updateRow(columnNames, MediaColumns.DOCUMENT_ID, row, null);
updateRow(columnNames, MediaColumns.INSTANCE_ID, row, null);
@@ -3026,15 +3100,6 @@
return null;
}
- static private String getPathForRedactedUriId(@NonNull String displayName) {
- return getStorageRootPathForUid(Binder.getCallingUid()) + "/" + REDACTED_URI_DIR + "/"
- + displayName;
- }
-
- static private String getStorageRootPathForUid(int uid) {
- return "/storage/emulated/" + (uid / PER_USER_RANGE);
- }
-
private void updateRow(HashSet<String> columnNames, String columnName,
MatrixCursor.RowBuilder row, Object val) {
if (columnNames.contains(columnName)) {
@@ -5673,7 +5738,7 @@
}
case MediaStore.SCAN_FILE_CALL:
case MediaStore.SCAN_VOLUME_CALL: {
- final int userId = Binder.getCallingUid() / PER_USER_RANGE;
+ final int userId = uidToUserId(Binder.getCallingUid());
final LocalCallingIdentity token = clearLocalCallingIdentity();
final CallingIdentity providerToken = clearCallingIdentity();
try {
@@ -8269,6 +8334,7 @@
boolean isSuccess = false;
final int originalUid = getBinderUidForFuse(uid, tid);
+ final int callingUserId = uidToUserId(uid);
int mediaCapabilitiesUid = 0;
final PendingOpenInfo pendingOpenInfo;
synchronized (mPendingOpenInfo) {
@@ -8282,7 +8348,7 @@
try {
boolean forceRedaction = false;
String redactedUriId = null;
- if (isSyntheticFilePathForRedactedUri(path, uid)) {
+ if (isRedactedPath(path, callingUserId)) {
if (forWrite) {
// Redacted URIs are not allowed to update EXIF headers.
return new FileOpenResult(OsConstants.EACCES /* status */, originalUid,
@@ -8298,7 +8364,7 @@
// Irrespective of the permissions we want to redact in this case.
redact = true;
forceRedaction = true;
- } else if (isSyntheticDirPath(path, uid)) {
+ } else if (isSyntheticPath(path, callingUserId)) {
// we don't support any other transformations under .transforms/synthetic dir
return new FileOpenResult(OsConstants.ENOENT /* status */, originalUid,
mediaCapabilitiesUid, new long[0]);
@@ -9756,11 +9822,13 @@
// it. It finds the first best child match and proceeds the match from there without
// looking at other siblings.
mPublic.addURI(auth, "picker", PICKER);
+ // TODO(b/195009139): Remove after switching picker URI to new format
// content://media/picker/<user-id>/<media-id>
mPublic.addURI(auth, "picker/#/#", PICKER_ID);
+ // content://media/picker/<user-id>/<authority>/media/<media-id>
+ mPublic.addURI(auth, "picker/#/*/media/*", PICKER_ID);
// content://media/picker/unreliable/<media_id>
mPublic.addURI(auth, "picker/unreliable/#", PICKER_UNRELIABLE_VOLUME);
-
mPublic.addURI(auth, "*/images/media", IMAGES_MEDIA);
mPublic.addURI(auth, "*/images/media/#", IMAGES_MEDIA_ID);
mPublic.addURI(auth, "*/images/media/#/thumbnail", IMAGES_MEDIA_ID_THUMBNAIL);
diff --git a/src/com/android/providers/media/PickerUriResolver.java b/src/com/android/providers/media/PickerUriResolver.java
index e71bc67..48a1dbb 100644
--- a/src/com/android/providers/media/PickerUriResolver.java
+++ b/src/com/android/providers/media/PickerUriResolver.java
@@ -35,10 +35,14 @@
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
import com.android.modules.utils.build.SdkLevel;
import com.android.providers.media.photopicker.data.model.UserId;
+import com.android.providers.media.photopicker.data.PickerDbFacade;
+import java.util.ArrayList;
+import java.util.List;
import java.io.FileNotFoundException;
/**
@@ -48,15 +52,18 @@
public class PickerUriResolver {
private Context mContext;
+ private static final String PICKER_SEGMENT = "picker";
+ private static final String PICKER_INTERNAL_SEGMENT = "picker_internal";
+
/** A uri with prefix "content://media/picker" is considered as a picker uri */
public static final Uri PICKER_URI = MediaStore.AUTHORITY_URI.buildUpon().
- appendPath("picker").build();
+ appendPath(PICKER_SEGMENT).build();
/**
* Internal picker URI with prefix "content://media/picker_internal" to retrieve merged
* and deduped cloud and local items.
*/
public static final Uri PICKER_INTERNAL_URI = MediaStore.AUTHORITY_URI.buildUpon().
- appendPath("picker_internal").build();
+ appendPath(PICKER_INTERNAL_SEGMENT).build();
PickerUriResolver(Context context) {
mContext = context;
@@ -74,7 +81,14 @@
final ContentResolver resolver = getContentResolverForUserId(uri);
final long token = Binder.clearCallingIdentity();
try {
- return resolver.openFile(getRedactedFileUriFromPickerUri(uri, resolver), "r", signal);
+ if (PickerDbFacade.isPickerDbEnabled()) {
+ // TODO(b/195009143): Redact before returning fd
+ uri = unwrapProviderUri(uri);
+ } else {
+ uri = getRedactedFileUriFromPickerUri(uri, resolver);
+ }
+
+ return resolver.openFile(uri, "r", signal);
} finally {
Binder.restoreCallingIdentity(token);
}
@@ -82,14 +96,20 @@
public AssetFileDescriptor openTypedAssetFile(Uri uri, String mimeTypeFilter, Bundle opts,
CancellationSignal signal, int callingPid, int callingUid)
- throws FileNotFoundException{
+ throws FileNotFoundException {
checkUriPermission(uri, callingPid, callingUid);
final ContentResolver resolver = getContentResolverForUserId(uri);
final long token = Binder.clearCallingIdentity();
try {
- return resolver.openTypedAssetFile(getRedactedFileUriFromPickerUri(uri, resolver),
- mimeTypeFilter, opts, signal);
+ if (PickerDbFacade.isPickerDbEnabled()) {
+ // TODO(b/195009143): Redact before returning fd
+ uri = unwrapProviderUri(uri);
+ } else {
+ uri = getRedactedFileUriFromPickerUri(uri, resolver);
+ }
+
+ return resolver.openTypedAssetFile(uri, mimeTypeFilter, opts, signal);
} finally {
Binder.restoreCallingIdentity(token);
}
@@ -142,6 +162,53 @@
}
}
+ public static Uri wrapProviderUri(Uri uri, int userId) {
+ final List<String> segments = uri.getPathSegments();
+ if (segments.size() != 2) {
+ throw new IllegalArgumentException("Unexpected provider URI: " + uri);
+ }
+
+ Uri.Builder builder = initializeUriBuilder(MediaStore.AUTHORITY);
+ builder.appendPath(PICKER_SEGMENT);
+ builder.appendPath(String.valueOf(userId));
+ builder.appendPath(uri.getHost());
+
+ for (int i = 0; i < segments.size(); i++) {
+ builder.appendPath(segments.get(i));
+ }
+
+ return builder.build();
+ }
+
+ @VisibleForTesting
+ static Uri unwrapProviderUri(Uri uri) {
+ List<String> segments = uri.getPathSegments();
+ if (segments.size() != 5) {
+ throw new IllegalArgumentException("Unexpected picker provider URI: " + uri);
+ }
+
+
+ // segments.get(0) == 'picker'
+ final String userId = segments.get(1);
+ final String host = segments.get(2);
+ segments = segments.subList(3, segments.size());
+
+ Uri.Builder builder = initializeUriBuilder(userId + "@" + host);
+
+ for (int i = 0; i < segments.size(); i++) {
+ builder.appendPath(segments.get(i));
+ }
+ return builder.build();
+ }
+
+ private static Uri.Builder initializeUriBuilder(String authority) {
+ final Uri.Builder builder = Uri.EMPTY.buildUpon();
+ builder.scheme("content");
+ builder.encodedAuthority(authority);
+
+ return builder;
+ }
+
/**
* @return {@link MediaStore.Files} Uri that always redacts sensitive data
*/
diff --git a/src/com/android/providers/media/TranscodeHelper.java b/src/com/android/providers/media/TranscodeHelper.java
index 5e2b13b..cdaa740 100644
--- a/src/com/android/providers/media/TranscodeHelper.java
+++ b/src/com/android/providers/media/TranscodeHelper.java
@@ -29,7 +29,7 @@
public boolean transcode(String src, String dst, int uid, int reason);
- public String getIoPath(String path, int uid);
+ public String prepareIoPath(String path, int uid);
public int shouldTranscode(String path, int uid, Bundle bundle);
diff --git a/src/com/android/providers/media/TranscodeHelperImpl.java b/src/com/android/providers/media/TranscodeHelperImpl.java
index a3e2731..c444ebe 100644
--- a/src/com/android/providers/media/TranscodeHelperImpl.java
+++ b/src/com/android/providers/media/TranscodeHelperImpl.java
@@ -32,6 +32,7 @@
import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__FAIL;
import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__SUCCESS;
import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED;
+import static com.android.providers.media.util.SyntheticPathUtils.createSparseFile;
import android.annotation.IntRange;
import android.annotation.LongDef;
@@ -105,7 +106,6 @@
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
-import java.io.RandomAccessFile;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.time.LocalDateTime;
@@ -523,7 +523,7 @@
* @param uid app requesting IO
*
*/
- public String getIoPath(String path, int uid) {
+ public String prepareIoPath(String path, int uid) {
// This can only happen when we are in a version that supports transcoding.
// So, no need to check for the SDK version here.
@@ -549,18 +549,12 @@
updateTranscodeStatus(path, TRANSCODE_EMPTY);
}
- final File file = new File(path);
- long maxFileSize = (long) (file.length() * 2);
- mTranscodeDirectory.mkdirs();
- try (RandomAccessFile raf = new RandomAccessFile(transcodeFile, "rw")) {
- raf.setLength(maxFileSize);
- } catch (IOException e) {
- Log.e(TAG, "Failed to initialise transcoding for file " + path, e);
- transcodeFile.delete();
+ final long maxFileSize = (long) (new File(path).length() * 2);
+ if (createSparseFile(transcodeFile, maxFileSize)) {
return transcodePath;
}
- return transcodePath;
+ return "";
}
private static int getMediaCapabilitiesUid(int uid, Bundle bundle) {
@@ -1189,6 +1183,7 @@
.setSourceFileDescriptor(srcPfd)
.setDestinationFileDescriptor(dstPfd)
.build();
+
TranscodingSession session = mediaTranscodeManager.enqueueRequest(request,
ForegroundThread.getExecutor(),
s -> {
diff --git a/src/com/android/providers/media/TranscodeHelperNoOp.java b/src/com/android/providers/media/TranscodeHelperNoOp.java
index ee4549b..77d3118 100644
--- a/src/com/android/providers/media/TranscodeHelperNoOp.java
+++ b/src/com/android/providers/media/TranscodeHelperNoOp.java
@@ -35,7 +35,7 @@
return false;
}
- public String getIoPath(String path, int uid) {
+ public String prepareIoPath(String path, int uid) {
return null;
}
diff --git a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
index 601514d..1735d42 100644
--- a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
+++ b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
@@ -41,6 +41,7 @@
import android.view.WindowManager;
import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.ViewModelProvider;
@@ -107,11 +108,12 @@
mToolbarHeight = ta.getDimensionPixelSize(0, -1);
ta.recycle();
- mPickerViewModel = new ViewModelProvider(this).get(PickerViewModel.class);
+ mPickerViewModel = createViewModel();
+
try {
mPickerViewModel.parseValuesFromIntent(getIntent());
} catch (IllegalArgumentException e) {
- Log.w(TAG, "Finish activity due to: " + e);
+ Log.e(TAG, "Finished activity due to an exception while parsing extras", e);
setCancelledResultAndFinishSelf();
}
@@ -125,6 +127,16 @@
mFragmentContainerView = findViewById(R.id.fragment_container);
}
+ /**
+ * Warning: This method is needed for tests, we are not customizing anything here.
+ * Allowing ourselves to control ViewModel creation helps us mock the ViewModel for test.
+ */
+ @VisibleForTesting
+ @NonNull
+ protected PickerViewModel createViewModel() {
+ return new ViewModelProvider(this).get(PickerViewModel.class);
+ }
+
@Override
public boolean dispatchTouchEvent(MotionEvent event){
if (event.getAction() == MotionEvent.ACTION_DOWN) {
@@ -329,16 +341,9 @@
public void setResultAndFinishSelf() {
final List<Item> selectedItemList = new ArrayList<>(
mPickerViewModel.getSelectedItems().getValue().values());
- // "persist.sys.photopicker.usepickeruri" property is used to indicate if picker uris should
- // be returned for all intent actions.
- // TODO(b/168001592): Remove this system property when intent-filter for ACTION_GET_CONTENT
- // is removed or when we don't have to send redactedUris any more.
- final boolean usePickerUriByDefault =
- SystemProperties.getBoolean("persist.sys.photopicker.usepickeruri", false);
- final boolean shouldReturnPickerUris = usePickerUriByDefault ||
- MediaStore.ACTION_PICK_IMAGES.equals(getIntent().getAction());
- setResult(Activity.RESULT_OK, getPickerResponseIntent(this, selectedItemList,
- shouldReturnPickerUris));
+
+ setResult(Activity.RESULT_OK, getPickerResponseIntent(mPickerViewModel.canSelectMultiple(),
+ selectedItemList));
finish();
}
diff --git a/src/com/android/providers/media/photopicker/PhotoPickerProvider.java b/src/com/android/providers/media/photopicker/PhotoPickerProvider.java
index b3a89b4..b137872 100644
--- a/src/com/android/providers/media/photopicker/PhotoPickerProvider.java
+++ b/src/com/android/providers/media/photopicker/PhotoPickerProvider.java
@@ -16,6 +16,7 @@
package com.android.providers.media.photopicker;
+import static android.provider.CloudMediaProviderContract.EXTRA_GENERATION;
import static android.provider.CloudMediaProviderContract.MediaInfo;
import android.content.ContentProviderClient;
@@ -125,7 +126,7 @@
}
private static long extractGeneration(@Nullable Bundle extras) {
- return extras == null ? 0 : extras.getLong(MediaInfo.MEDIA_GENERATION, 0);
+ return extras == null ? 0 : extras.getLong(EXTRA_GENERATION, 0);
}
private static String extractAlbum(@Nullable Bundle extras) {
diff --git a/src/com/android/providers/media/photopicker/PickerSyncController.java b/src/com/android/providers/media/photopicker/PickerSyncController.java
index 1ba5f8e..b9df063 100644
--- a/src/com/android/providers/media/photopicker/PickerSyncController.java
+++ b/src/com/android/providers/media/photopicker/PickerSyncController.java
@@ -16,6 +16,7 @@
package com.android.providers.media.photopicker;
+import static android.provider.CloudMediaProviderContract.EXTRA_GENERATION;
import static android.provider.CloudMediaProviderContract.MediaColumns;
import static android.provider.CloudMediaProviderContract.MediaInfo;
import static com.android.providers.media.PickerUriResolver.getMediaUri;
@@ -238,13 +239,22 @@
}
public String getCloudProvider() {
- return mCloudProvider;
+ synchronized (mLock) {
+ return mCloudProvider;
+ }
}
public String getLocalProvider() {
return mLocalProvider;
}
+ public boolean isProviderEnabled(String authority) {
+ synchronized (mLock) {
+ return authority.equals(mLocalProvider) || authority.equals(mCloudProvider);
+ }
+ }
+
+
/**
* Notifies about media events like inserts/updates/deletes from cloud and local providers and
* syncs the changes in the background.
@@ -275,7 +285,7 @@
// Sync media
final Bundle queryArgs = new Bundle();
final long cachedGeneration = cachedMediaInfo.getLong(MediaInfo.MEDIA_GENERATION);
- queryArgs.putLong(MediaInfo.MEDIA_GENERATION, cachedGeneration);
+ queryArgs.putLong(EXTRA_GENERATION, cachedGeneration);
try (Cursor cursor = query(getMediaUri(authority), queryArgs)) {
result = mDbFacade.addMedia(cursor, authority);
@@ -285,7 +295,7 @@
// Sync deleted_media
final Bundle queryDeletedArgs = new Bundle();
- queryDeletedArgs.putLong(MediaInfo.MEDIA_GENERATION, cachedGeneration);
+ queryDeletedArgs.putLong(EXTRA_GENERATION, cachedGeneration);
try (Cursor cursor = query(getDeletedMediaUri(authority), queryDeletedArgs)) {
final int idIndex = cursor.getColumnIndex(MediaColumns.ID);
diff --git a/src/com/android/providers/media/photopicker/data/ItemsProvider.java b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
index c35ef9b..6c3c249 100644
--- a/src/com/android/providers/media/photopicker/data/ItemsProvider.java
+++ b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
@@ -217,8 +217,9 @@
// DATE_TAKEN is time in milliseconds, whereas DATE_MODIFIED is time in seconds.
// Sort by DATE_MODIFIED if DATE_TAKEN is NULL
extras.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER,
- "COALESCE(" + MediaColumns.DATE_TAKEN + "," + MediaColumns.DATE_MODIFIED +
- "* 1000) DESC");
+ "COALESCE(" + MediaColumns.DATE_TAKEN + ","
+ + MediaColumns.DATE_MODIFIED + "* 1000) DESC, "
+ + MediaColumns._ID + " DESC");
extras.putInt(ContentResolver.QUERY_ARG_OFFSET, offset);
if (limit != -1) {
extras.putInt(ContentResolver.QUERY_ARG_LIMIT, limit);
@@ -266,7 +267,10 @@
Long.parseLong(id));
} else {
// We only have authority after querying the picker db
- uri = PickerUriResolver.getMediaUri(authority).buildUpon().appendPath(id).build();
+ final Uri providerUri = PickerUriResolver.getMediaUri(authority).buildUpon()
+ .appendPath(id).build();
+ uri = PickerUriResolver.wrapProviderUri(providerUri,
+ userId.getUserHandle().getIdentifier());
}
if (userId.equals(UserId.CURRENT_USER)) {
diff --git a/src/com/android/providers/media/photopicker/data/PickerDbFacade.java b/src/com/android/providers/media/photopicker/data/PickerDbFacade.java
index d5e21c9..f557b50 100644
--- a/src/com/android/providers/media/photopicker/data/PickerDbFacade.java
+++ b/src/com/android/providers/media/photopicker/data/PickerDbFacade.java
@@ -513,6 +513,26 @@
return queryMedia(qb, selectionArgs, query.limit);
}
+ public Cursor queryMediaId(String authority, String mediaId) {
+ final String[] selectionArgs = new String[] { mediaId };
+ final SQLiteQueryBuilder qb = createVisibleMediaQueryBuilder();
+ if (isLocal(authority)) {
+ qb.appendWhereStandalone(WHERE_LOCAL_ID);
+ } else {
+ qb.appendWhereStandalone(WHERE_CLOUD_ID);
+ }
+
+ synchronized (mLock) {
+ if (authority.equals(mLocalProvider) || authority.equals(mCloudProvider)) {
+ return qb.query(mDatabase, getProjectionLocked(), /* selection */ null,
+ selectionArgs, /* groupBy */ null, /* having */ null, /* orderBy */ null,
+ /* limitStr */ null);
+ }
+ }
+
+ return null;
+ }
+
public static boolean isPickerDbEnabled() {
return SystemProperties.getBoolean("sys.photopicker.pickerdb.enabled", false);
}
@@ -531,20 +551,22 @@
qb.appendWhereStandalone(WHERE_NULL_CLOUD_ID);
}
- final String[] projection = new String[] {
- getProjectionAuthorityLocked(),
- PROJECTION_ID,
- PROJECTION_DATE_TAKEN,
- PROJECTION_SIZE,
- PROJECTION_DURATION,
- PROJECTION_MIME_TYPE
- };
-
- return qb.query(mDatabase, projection, /* selection */ null, selectionArgs,
+ return qb.query(mDatabase, getProjectionLocked(), /* selection */ null, selectionArgs,
/* groupBy */ null, /* having */ null, orderBy, limitStr);
}
}
+ private String[] getProjectionLocked() {
+ return new String[] {
+ getProjectionAuthorityLocked(),
+ PROJECTION_ID,
+ PROJECTION_DATE_TAKEN,
+ PROJECTION_SIZE,
+ PROJECTION_DURATION,
+ PROJECTION_MIME_TYPE
+ };
+ }
+
private String getProjectionAuthorityLocked() {
if (mCloudProvider == null) {
return String.format("'%s' AS %s", mLocalProvider,
diff --git a/src/com/android/providers/media/photopicker/data/PickerResult.java b/src/com/android/providers/media/photopicker/data/PickerResult.java
index 3e08880..2ddada3 100644
--- a/src/com/android/providers/media/photopicker/data/PickerResult.java
+++ b/src/com/android/providers/media/photopicker/data/PickerResult.java
@@ -32,6 +32,8 @@
import com.android.providers.media.photopicker.data.model.Item;
import com.android.providers.media.photopicker.data.model.UserId;
+import com.google.common.annotations.VisibleForTesting;
+
import java.util.ArrayList;
import java.util.List;
@@ -44,52 +46,37 @@
* @return {@code Intent} which contains Uri that has been granted access on.
*/
@NonNull
- public static Intent getPickerResponseIntent(@NonNull Context context,
+ public static Intent getPickerResponseIntent(boolean canSelectMultiple,
@NonNull List<Item> selectedItems) {
- return getPickerResponseIntent(context, selectedItems, /* shouldReturnPickerUris */ true);
- }
-
- /**
- * @return {@code Intent} which contains Uri that has been granted access on.
- * TODO(b/168001592): Remove this method and merge it with actual method
- * {@link PickerResult#getPickerResponseIntent(Context, List)} when intent-filter for
- * ACTION_GET_CONTENT is removed or when we don't have to send redactedUris any more.
- */
- @NonNull
- public static Intent getPickerResponseIntent(@NonNull Context context,
- @NonNull List<Item> selectedItems, boolean shouldReturnPickerUris) {
// 1. Get Picker Uris corresponding to the selected items
- List<Uri> selectedUris;
- if (shouldReturnPickerUris) {
- selectedUris = getPickerUrisForItems(selectedItems);
- } else {
- selectedUris = getRedactedUrisForItems(context.getContentResolver(), selectedItems);
- }
+ List<Uri> selectedUris = getPickerUrisForItems(selectedItems);
// 2. Grant read access to picker Uris and return
Intent intent = new Intent();
final int size = selectedUris.size();
- if (size == 1) {
- intent.setData(selectedUris.get(0));
- } else if (size > 1) {
- // TODO (b/169737761): use correct mime types
- String[] mimeTypes = new String[]{"image/*", "video/*"};
- final ClipData clipData = new ClipData(null /* label */, mimeTypes,
- new ClipData.Item(selectedUris.get(0)));
- for (int i = 1; i < size; i++) {
- clipData.addItem(new ClipData.Item(selectedUris.get(i)));
- }
- intent.setClipData(clipData);
- } else {
+ if (size < 1) {
// TODO (b/168783994): check if this is ever possible. If yes, handle properly,
- // if not, change the above "else if" block to "else" block.
+ // if not, remove this if block.
+ return intent;
}
+ if (!canSelectMultiple) {
+ intent.setData(selectedUris.get(0));
+ }
+ // TODO (b/169737761): use correct mime types
+ String[] mimeTypes = new String[]{"image/*", "video/*"};
+ final ClipData clipData = new ClipData(null /* label */, mimeTypes,
+ new ClipData.Item(selectedUris.get(0)));
+ for (int i = 1; i < size; i++) {
+ clipData.addItem(new ClipData.Item(selectedUris.get(i)));
+ }
+ intent.setClipData(clipData);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
return intent;
}
- private static Uri getPickerUri(Uri uri, String id) {
+ @VisibleForTesting
+ static Uri getPickerUri(Uri uri, String id) {
final String userInfo = uri.getUserInfo();
final String userId = userInfo == null ? UserId.CURRENT_USER.toString() : userInfo;
final Uri uriWithUserId =
diff --git a/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java b/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java
index 04824a2..eafb874 100644
--- a/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java
+++ b/src/com/android/providers/media/photopicker/ui/AlbumsTabFragment.java
@@ -36,6 +36,8 @@
*/
public class AlbumsTabFragment extends TabFragment {
+ private static final int MINIMUM_SPAN_COUNT = 2;
+
private int mBottomBarGap;
@Override
@@ -56,6 +58,7 @@
final int spacing = getResources().getDimensionPixelSize(R.dimen.picker_album_item_spacing);
final int albumSize = getResources().getDimensionPixelSize(R.dimen.picker_album_size);
mRecyclerView.setColumnWidth(albumSize + spacing);
+ mRecyclerView.setMinimumSpanCount(MINIMUM_SPAN_COUNT);
mRecyclerView.setLayoutManager(layoutManager);
mRecyclerView.setAdapter(adapter);
diff --git a/src/com/android/providers/media/photopicker/ui/AutoFitRecyclerView.java b/src/com/android/providers/media/photopicker/ui/AutoFitRecyclerView.java
index 687ae27..526d090 100644
--- a/src/com/android/providers/media/photopicker/ui/AutoFitRecyclerView.java
+++ b/src/com/android/providers/media/photopicker/ui/AutoFitRecyclerView.java
@@ -27,7 +27,9 @@
* The AutoFitRecyclerView auto fits the column width to decide the span count
*/
public class AutoFitRecyclerView extends RecyclerView {
+
private int mColumnWidth = -1;
+ private int mMinimumSpanCount = 2;
private boolean mIsGridLayout;
public AutoFitRecyclerView(Context context) {
@@ -47,7 +49,7 @@
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mIsGridLayout && mColumnWidth > 0) {
- final int spanCount = Math.max(1, getMeasuredWidth() / mColumnWidth);
+ final int spanCount = Math.max(mMinimumSpanCount, getMeasuredWidth() / mColumnWidth);
((GridLayoutManager) getLayoutManager()).setSpanCount(spanCount);
}
}
@@ -63,4 +65,12 @@
public void setColumnWidth(int columnWidth) {
mColumnWidth = columnWidth;
}
+
+ /**
+ * Set the minimum span count for the recyclerView.
+ * @param minimumSpanCount The default value is 2.
+ */
+ public void setMinimumSpanCount(int minimumSpanCount) {
+ mMinimumSpanCount = minimumSpanCount;
+ }
}
diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
index 076e918..2c8f791 100644
--- a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
+++ b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
@@ -46,6 +46,7 @@
*/
public class PhotosTabFragment extends TabFragment {
+ private static final int MINIMUM_SPAN_COUNT = 3;
private static final String FRAGMENT_TAG = "PhotosTabFragment";
private static final String EXTRA_CATEGORY_TYPE = "category_type";
private static final String EXTRA_CATEGORY_NAME = "category_name";
@@ -97,6 +98,7 @@
final int spacing = getResources().getDimensionPixelSize(R.dimen.picker_photo_item_spacing);
final int photoSize = getResources().getDimensionPixelSize(R.dimen.picker_photo_size);
mRecyclerView.setColumnWidth(photoSize + spacing);
+ mRecyclerView.setMinimumSpanCount(MINIMUM_SPAN_COUNT);
mRecyclerView.setLayoutManager(layoutManager);
mRecyclerView.setAdapter(adapter);
diff --git a/src/com/android/providers/media/photopicker/ui/PreviewFragment.java b/src/com/android/providers/media/photopicker/ui/PreviewFragment.java
index 14c3695..0abc413 100644
--- a/src/com/android/providers/media/photopicker/ui/PreviewFragment.java
+++ b/src/com/android/providers/media/photopicker/ui/PreviewFragment.java
@@ -31,6 +31,7 @@
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
+import androidx.viewpager2.widget.MarginPageTransformer;
import androidx.viewpager2.widget.ViewPager2;
import com.android.providers.media.R;
@@ -97,6 +98,8 @@
// Initialize ViewPager2 to swipe between multiple pictures/videos in preview
mViewPager = view.findViewById(R.id.preview_viewPager);
mViewPager.setAdapter(mAdapter);
+ mViewPager.setPageTransformer(new MarginPageTransformer(
+ getResources().getDimensionPixelSize(R.dimen.preview_viewpager_margin)));
Button selectButton = view.findViewById(R.id.preview_select_button);
diff --git a/src/com/android/providers/media/photopicker/ui/TabFragment.java b/src/com/android/providers/media/photopicker/ui/TabFragment.java
index be92ce1..ab6c02b 100644
--- a/src/com/android/providers/media/photopicker/ui/TabFragment.java
+++ b/src/com/android/providers/media/photopicker/ui/TabFragment.java
@@ -70,7 +70,7 @@
super.onViewCreated(view, savedInstanceState);
mImageLoader = new ImageLoader(getContext());
- mRecyclerView = view.findViewById(R.id.photo_list);
+ mRecyclerView = view.findViewById(R.id.picker_tab_recyclerview);
mRecyclerView.setHasFixedSize(true);
mPickerViewModel = new ViewModelProvider(requireActivity()).get(PickerViewModel.class);
diff --git a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
index 70d95e7..69c4b94 100644
--- a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
+++ b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
@@ -58,7 +58,6 @@
public static final String TAG = "PhotoPicker";
private static final int RECENT_MINIMUM_COUNT = 12;
- public static final int DEFAULT_MAX_SELECTION_LIMIT = 100;
// TODO(b/193857982): We keep these four data sets now, we may need to find a way to reduce the
// data set to reduce memories.
@@ -72,10 +71,10 @@
private MutableLiveData<List<Category>> mCategoryList;
private ItemsProvider mItemsProvider;
- private final UserIdManager mUserIdManager;
+ private UserIdManager mUserIdManager;
private boolean mSelectMultiple = false;
private String mMimeTypeFilter = null;
- private int mMaxSelectionLimit = DEFAULT_MAX_SELECTION_LIMIT;
+ private int mMaxSelectionLimit = 1;
// This is set to false when max selection limit is reached.
private boolean mIsSelectionAllowed = true;
private int mBottomSheetState;
@@ -88,10 +87,15 @@
}
@VisibleForTesting
- void setItemsProvider(@NonNull ItemsProvider itemsProvider) {
+ public void setItemsProvider(@NonNull ItemsProvider itemsProvider) {
mItemsProvider = itemsProvider;
}
+ @VisibleForTesting
+ public void setUserIdManager(@NonNull UserIdManager userIdManager) {
+ mUserIdManager = userIdManager;
+ }
+
/**
* @return the {@link LiveData} of selected items {@link #mSelectedItemList}.
*/
@@ -342,8 +346,6 @@
public void parseValuesFromIntent(Intent intent) throws IllegalArgumentException {
mUserIdManager.setIntentAndCheckRestrictions(intent);
- mSelectMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false);
-
final String mimeType = intent.getType();
if (isMimeTypeMedia(mimeType)) {
mMimeTypeFilter = mimeType;
@@ -352,19 +354,32 @@
final Bundle extras = intent.getExtras();
final boolean isExtraPickImagesMaxSet =
extras != null && extras.containsKey(MediaStore.EXTRA_PICK_IMAGES_MAX);
- // 1. Check EXTRA_PICK_IMAGES_MAX only if EXTRA_ALLOW_MULTIPLE is set.
- // 2. Do not show "Set up to max items" message if EXTRA_PICK_IMAGES_MAX is not set
- if (mSelectMultiple && isExtraPickImagesMaxSet) {
+
+ // Support Intent.EXTRA_ALLOW_MULTIPLE flag only for ACTION_GET_CONTENT
+ if (intent.getAction() != null && intent.getAction().equals(Intent.ACTION_GET_CONTENT)) {
+ if (isExtraPickImagesMaxSet) {
+ throw new IllegalArgumentException("EXTRA_PICK_IMAGES_MAX is not supported for "
+ + "ACTION_GET_CONTENT");
+ }
+
+ mSelectMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false);
+ if (mSelectMultiple) {
+ mMaxSelectionLimit = MediaStore.getPickImagesMaxLimit();
+ }
+ return;
+ }
+
+ // Check EXTRA_PICK_IMAGES_MAX value only if the flag is set.
+ if (isExtraPickImagesMaxSet) {
final int extraMax = intent.getIntExtra(MediaStore.EXTRA_PICK_IMAGES_MAX,
/* defaultValue */ -1);
- // Multi selection max limit should always be greater than 0
- if (extraMax <= 0) {
+ // Multi selection max limit should always be greater than 1 and less than or equal
+ // to PICK_IMAGES_MAX_LIMIT.
+ if (extraMax <= 1 || extraMax > MediaStore.getPickImagesMaxLimit()) {
throw new IllegalArgumentException("Invalid EXTRA_PICK_IMAGES_MAX value");
}
- // Multi selection limit should always be less than global max values allowed to select.
- if (extraMax <= DEFAULT_MAX_SELECTION_LIMIT) {
- mMaxSelectionLimit = extraMax;
- }
+ mSelectMultiple = true;
+ mMaxSelectionLimit = extraMax;
}
}
diff --git a/src/com/android/providers/media/util/FileUtils.java b/src/com/android/providers/media/util/FileUtils.java
index 0e50fd8..cfe8a2c 100644
--- a/src/com/android/providers/media/util/FileUtils.java
+++ b/src/com/android/providers/media/util/FileUtils.java
@@ -88,6 +88,7 @@
import java.util.Collection;
import java.util.Comparator;
import java.util.Iterator;
+import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@@ -1645,4 +1646,8 @@
return null;
}
+
+ public static File buildPrimaryVolumeFile(int userId, String... segments) {
+ return buildPath(new File("/storage/emulated/" + userId), segments);
+ }
}
diff --git a/src/com/android/providers/media/util/SyntheticPathUtils.java b/src/com/android/providers/media/util/SyntheticPathUtils.java
new file mode 100644
index 0000000..0b7cb94
--- /dev/null
+++ b/src/com/android/providers/media/util/SyntheticPathUtils.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.util;
+
+import static com.android.providers.media.util.FileUtils.buildPath;
+import static com.android.providers.media.util.FileUtils.buildPrimaryVolumeFile;
+import static com.android.providers.media.util.FileUtils.extractFileName;
+
+import androidx.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public final class SyntheticPathUtils {
+ private static final String TAG = "SyntheticPathUtils";
+
+ private static final String TRANSFORMS_DIR = ".transforms";
+ private static final String SYNTHETIC_DIR = "synthetic";
+ private static final String REDACTED_DIR = "redacted";
+ private static final String PICKER_DIR = "picker";
+
+ public static final String REDACTED_URI_ID_PREFIX = "RUID";
+ public static final int REDACTED_URI_ID_SIZE = 36;
+
+ private SyntheticPathUtils() {}
+
+ public static String getRedactedRelativePath() {
+ return buildPath(/* base */ null, TRANSFORMS_DIR, SYNTHETIC_DIR, REDACTED_DIR).getPath();
+ }
+
+ public static String getPickerRelativePath() {
+ return buildPath(/* base */ null, TRANSFORMS_DIR, SYNTHETIC_DIR, PICKER_DIR).getPath();
+ }
+
+ public static boolean isRedactedPath(String path, int userId) {
+ if (path == null) return false;
+
+ final String redactedDir = buildPrimaryVolumeFile(userId, getRedactedRelativePath())
+ .getAbsolutePath();
+ final String fileName = extractFileName(path);
+
+ return fileName != null
+ && startsWith(path, redactedDir)
+ && startsWith(fileName, REDACTED_URI_ID_PREFIX)
+ && fileName.length() == REDACTED_URI_ID_SIZE;
+ }
+
+ public static boolean isPickerPath(String path, int userId) {
+ final String pickerDir = buildPrimaryVolumeFile(userId, getPickerRelativePath())
+ .getAbsolutePath();
+
+ return path != null && startsWith(path, pickerDir);
+ }
+
+ public static boolean isSyntheticPath(String path, int userId) {
+ final String syntheticDir = buildPrimaryVolumeFile(userId, getSyntheticRelativePath())
+ .getAbsolutePath();
+
+ return path != null && startsWith(path, syntheticDir);
+ }
+
+ public static List<String> extractSyntheticRelativePathSegements(String path, int userId) {
+ final List<String> segments = new ArrayList<>();
+ final String syntheticDir = buildPrimaryVolumeFile(userId, getSyntheticRelativePath())
+ .getAbsolutePath();
+
+ if (path.toLowerCase(Locale.ROOT).indexOf(syntheticDir.toLowerCase(Locale.ROOT)) < 0) {
+ return segments;
+ }
+
+ final String[] segmentArray = path.substring(syntheticDir.length()).split("/");
+ for (String segment : segmentArray) {
+ if (TextUtils.isEmpty(segment)) {
+ continue;
+ }
+ segments.add(segment);
+ }
+
+ return segments;
+ }
+
+ public static boolean createSparseFile(File file, long size) {
+ if (size < 0) {
+ return false;
+ }
+
+ try (RandomAccessFile raf = new RandomAccessFile(file, "rw")) {
+ raf.setLength(size);
+ return true;
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to create sparse file: " + file, e);
+ file.delete();
+ return false;
+ }
+ }
+
+ @VisibleForTesting
+ static String getSyntheticRelativePath() {
+ return buildPath(/* base */ null, TRANSFORMS_DIR, SYNTHETIC_DIR).getPath();
+ }
+
+ private static boolean startsWith(String value, String prefix) {
+ return value.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT));
+ }
+}
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
index 6fa0824..15c2be4 100644
--- a/tests/AndroidManifest.xml
+++ b/tests/AndroidManifest.xml
@@ -15,18 +15,20 @@
<activity android:name="com.android.providers.media.GetResultActivity" />
<activity android:name="com.android.providers.media.PermissionActivity" />
<activity android:name="com.android.providers.media.CacheClearingActivity" />
- <activity android:name="com.android.providers.media.photopicker.PhotoPickerActivity"
+ <activity android:name="com.android.providers.media.photopicker.espresso.PhotoPickerTestActivity"
android:theme="@style/PickerDefaultTheme"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="android.provider.action.PICK_IMAGES" />
<category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.FRAMEWORK_INSTRUMENTATION_TEST" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.provider.action.PICK_IMAGES" />
<category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.FRAMEWORK_INSTRUMENTATION_TEST" />
</intent-filter>
</activity>
diff --git a/tests/AndroidTest.xml b/tests/AndroidTest.xml
index 1cae732..7fd80d5 100644
--- a/tests/AndroidTest.xml
+++ b/tests/AndroidTest.xml
@@ -30,8 +30,14 @@
<option name="package" value="com.android.providers.media.tests" />
<option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
<option name="hidden-api-checks" value="false"/>
+ <option name="instrumentation-arg" key="thisisignored" value="thisisignored --no-window-animation" />
</test>
+ <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
+ <option name="run-command" value="input keyevent KEYCODE_WAKEUP" />
+ <option name="run-command" value="wm dismiss-keyguard" />
+ </target_preparer>
+
<object type="module_controller" class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
<option name="mainline-module-package-name" value="com.google.android.mediaprovider" />
</object>
diff --git a/tests/src/com/android/providers/media/PickerUriResolverTest.java b/tests/src/com/android/providers/media/PickerUriResolverTest.java
new file mode 100644
index 0000000..4079112
--- /dev/null
+++ b/tests/src/com/android/providers/media/PickerUriResolverTest.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import android.net.Uri;
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class PickerUriResolverTest {
+ @Test
+ public void wrapProviderUriValid() throws Exception {
+ final String providerSuffix = "authority/media/media_id";
+
+ final Uri providerUriUserImplicit = Uri.parse("content://" + providerSuffix);
+
+ final Uri providerUriUser0 = Uri.parse("content://0@" + providerSuffix);
+ final Uri mediaUriUser0 = Uri.parse("content://media/picker/0/" + providerSuffix);
+
+ final Uri providerUriUser10 = Uri.parse("content://10@" + providerSuffix);
+ final Uri mediaUriUser10 = Uri.parse("content://media/picker/10/" + providerSuffix);
+
+ assertThat(PickerUriResolver.wrapProviderUri(providerUriUserImplicit, 0))
+ .isEqualTo(mediaUriUser0);
+ assertThat(PickerUriResolver.wrapProviderUri(providerUriUser0, 0)).isEqualTo(mediaUriUser0);
+ assertThat(PickerUriResolver.unwrapProviderUri(mediaUriUser0)).isEqualTo(providerUriUser0);
+
+ assertThat(PickerUriResolver.wrapProviderUri(providerUriUserImplicit, 10))
+ .isEqualTo(mediaUriUser10);
+ assertThat(PickerUriResolver.wrapProviderUri(providerUriUser10, 10))
+ .isEqualTo(mediaUriUser10);
+ assertThat(PickerUriResolver.unwrapProviderUri(mediaUriUser10))
+ .isEqualTo(providerUriUser10);
+ }
+
+ @Test
+ public void wrapProviderUriInvalid() throws Exception {
+ final String providerSuffixLong = "authority/media/media_id/another_media_id";
+ final String providerSuffixShort = "authority/media";
+
+ final Uri providerUriUserLong = Uri.parse("content://0@" + providerSuffixLong);
+ final Uri mediaUriUserLong = Uri.parse("content://media/picker/0/" + providerSuffixLong);
+
+ final Uri providerUriUserShort = Uri.parse("content://0@" + providerSuffixShort);
+ final Uri mediaUriUserShort = Uri.parse("content://media/picker/0/" + providerSuffixShort);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> PickerUriResolver.wrapProviderUri(providerUriUserLong, 0));
+ assertThrows(IllegalArgumentException.class,
+ () -> PickerUriResolver.unwrapProviderUri(mediaUriUserLong));
+
+ assertThrows(IllegalArgumentException.class,
+ () -> PickerUriResolver.unwrapProviderUri(mediaUriUserShort));
+ assertThrows(IllegalArgumentException.class,
+ () -> PickerUriResolver.wrapProviderUri(providerUriUserShort, 0));
+ }
+
+ private static <T extends Exception> void assertThrows(Class<T> clazz, Runnable r) {
+ try {
+ r.run();
+ fail("Expected " + clazz + " to be thrown");
+ } catch (Exception e) {
+ if (!clazz.isAssignableFrom(e.getClass())) {
+ throw e;
+ }
+ }
+ }
+}
diff --git a/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java b/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java
index e129b6f..43deb65 100644
--- a/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java
+++ b/tests/src/com/android/providers/media/cloudproviders/CloudProviderPrimary.java
@@ -16,7 +16,9 @@
package com.android.providers.media.cloudproviders;
+import static android.provider.CloudMediaProviderContract.EXTRA_GENERATION;
import static android.provider.CloudMediaProviderContract.MediaInfo;
+
import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator;
import android.content.res.AssetFileDescriptor;
@@ -26,7 +28,7 @@
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.provider.CloudMediaProvider;
-import com.android.providers.media.MediaProvider;
+
import com.android.providers.media.PickerProviderMediaGenerator;
import java.io.FileNotFoundException;
@@ -85,6 +87,6 @@
}
private static long getGeneration(Bundle extras) {
- return extras == null ? 0 : extras.getLong(MediaInfo.MEDIA_GENERATION, 0);
+ return extras == null ? 0 : extras.getLong(EXTRA_GENERATION, 0);
}
}
diff --git a/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java b/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java
index dbd9b93..8e718df 100644
--- a/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java
+++ b/tests/src/com/android/providers/media/cloudproviders/CloudProviderSecondary.java
@@ -16,7 +16,9 @@
package com.android.providers.media.cloudproviders;
+import static android.provider.CloudMediaProviderContract.EXTRA_GENERATION;
import static android.provider.CloudMediaProviderContract.MediaInfo;
+
import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator;
import android.content.res.AssetFileDescriptor;
@@ -26,7 +28,7 @@
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.provider.CloudMediaProvider;
-import com.android.providers.media.MediaProvider;
+
import com.android.providers.media.PickerProviderMediaGenerator;
import java.io.FileNotFoundException;
@@ -85,6 +87,6 @@
}
private static long getGeneration(Bundle extras) {
- return extras == null ? 0 : extras.getLong(MediaInfo.MEDIA_GENERATION, 0);
+ return extras == null ? 0 : extras.getLong(EXTRA_GENERATION, 0);
}
}
diff --git a/tests/src/com/android/providers/media/photopicker/AutoFitRecyclerViewTest.java b/tests/src/com/android/providers/media/photopicker/AutoFitRecyclerViewTest.java
new file mode 100644
index 0000000..1478107
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/AutoFitRecyclerViewTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.view.View;
+
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.test.InstrumentationRegistry;
+
+import com.android.providers.media.photopicker.ui.AutoFitRecyclerView;
+
+import org.junit.Test;
+
+public class AutoFitRecyclerViewTest {
+
+ @Test
+ public void testSetMinimumSpanCount_resultSmallerThanMinSpanCount() {
+ final int defaultSpanCount = 1;
+ final int minSpanCount = 3;
+ final int size = 1200;
+ final int columnWidth = size;
+ final int measureSpec = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY);
+ final Context context = InstrumentationRegistry.getTargetContext();
+ AutoFitRecyclerView recyclerView = new AutoFitRecyclerView(context);
+ final GridLayoutManager gridLayoutManager = new GridLayoutManager(context,
+ defaultSpanCount);
+
+ assertThat(gridLayoutManager.getSpanCount()).isEqualTo(defaultSpanCount);
+
+ recyclerView.setLayoutManager(gridLayoutManager);
+ recyclerView.setMinimumSpanCount(minSpanCount);
+ recyclerView.setColumnWidth(columnWidth);
+
+ recyclerView.onMeasure(measureSpec, measureSpec);
+
+ // The column width equals the measured width, the calculated count is 1. It is smaller than
+ // minSpanCount, we expected the span count equals minSpanCount.
+ assertThat(gridLayoutManager.getSpanCount()).isEqualTo(minSpanCount);
+ }
+
+ @Test
+ public void testSetMinimumSpanCount_resultLargerThanMinSpanCount() {
+ final int defaultSpanCount = 1;
+ final int minSpanCount = 3;
+ final int size = 1200;
+ final int columnWidth = 300;
+ final int expectedSpanCount = size / columnWidth;
+ final int measureSpec = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY);
+ final Context context = InstrumentationRegistry.getTargetContext();
+ AutoFitRecyclerView recyclerView = new AutoFitRecyclerView(context);
+ final GridLayoutManager gridLayoutManager = new GridLayoutManager(context,
+ defaultSpanCount);
+
+ assertThat(gridLayoutManager.getSpanCount()).isEqualTo(defaultSpanCount);
+
+ recyclerView.setLayoutManager(gridLayoutManager);
+ recyclerView.setMinimumSpanCount(minSpanCount);
+ recyclerView.setColumnWidth(columnWidth);
+
+ recyclerView.onMeasure(measureSpec, measureSpec);
+
+ // The calculated count is 4. It is larger than minSpanCount. Verify the span count equals
+ // expectedSpanCount.
+ assertThat(gridLayoutManager.getSpanCount()).isEqualTo(expectedSpanCount);
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java
index 597b604..e869423 100644
--- a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java
+++ b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java
@@ -22,6 +22,7 @@
import static com.android.providers.media.util.MimeUtils.isVideoMimeType;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
import android.Manifest;
import android.content.ContentResolver;
@@ -38,6 +39,7 @@
import com.android.providers.media.photopicker.data.ItemsProvider;
import com.android.providers.media.photopicker.data.model.Category;
+import com.android.providers.media.photopicker.data.model.Item;
import com.android.providers.media.photopicker.data.model.UserId;
import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
@@ -45,6 +47,9 @@
import org.junit.Test;
import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
public class ItemsProviderTest {
@@ -59,9 +64,8 @@
private static final String IMAGE_FILE_NAME = TAG + "_file_" + NONCE + ".jpg";
private static final String HIDDEN_DIR_NAME = TAG + "_hidden_dir_" + NONCE;
- private static Context sIsolatedContext;
- private static ContentResolver sIsolatedResolver;
- private static ItemsProvider sItemsProvider;
+ private ContentResolver mIsolatedResolver;
+ private ItemsProvider mItemsProvider;
@Before
public void setUp() {
@@ -72,12 +76,13 @@
Manifest.permission.INTERACT_ACROSS_USERS);
final Context context = InstrumentationRegistry.getTargetContext();
- sIsolatedContext = new IsolatedContext(context, "modern", /*asFuseThread*/ false);
- sIsolatedResolver = sIsolatedContext.getContentResolver();
- sItemsProvider = new ItemsProvider(sIsolatedContext);
+ final Context isolatedContext
+ = new IsolatedContext(context, "modern", /*asFuseThread*/ false);
+ mIsolatedResolver = isolatedContext.getContentResolver();
+ mItemsProvider = new ItemsProvider(isolatedContext);
// Wait for MediaStore to be Idle to reduce flakes caused by database updates
- MediaStore.waitForIdle(sIsolatedResolver);
+ MediaStore.waitForIdle(mIsolatedResolver);
}
/**
@@ -86,7 +91,7 @@
*/
@Test
public void testGetCategories_camera() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// Create 1 image file in Camera dir to test
@@ -106,7 +111,7 @@
*/
@Test
public void testGetCategories_not_camera() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// negative test case: image file which should not be returned in Camera category
@@ -125,7 +130,7 @@
*/
@Test
public void testGetCategories_videos() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// Create 1 video file in Movies dir to test
@@ -145,7 +150,7 @@
*/
@Test
public void testGetCategories_not_videos() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// negative test case: image file which should not be returned in Videos category
@@ -164,7 +169,7 @@
*/
@Test
public void testGetCategories_screenshots() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// Create 1 image file in Screenshots dir to test
@@ -184,7 +189,7 @@
*/
@Test
public void testGetCategories_not_screenshots() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// negative test case: image file which should not be returned in Screenshots category
@@ -203,7 +208,7 @@
*/
@Test
public void testGetCategories_favorites() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// positive test case: image file which should be returned in favorites category
@@ -223,7 +228,7 @@
*/
@Test
public void testGetCategories_not_favorites() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// negative test case: image file which should not be returned in favorites category
@@ -242,7 +247,7 @@
*/
@Test
public void testGetCategories_downloads() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// Create 1 image file in Downloads dir to test
@@ -262,7 +267,7 @@
*/
@Test
public void testGetCategories_not_downloads() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// negative test case: image file which should not be returned in Downloads category
@@ -281,7 +286,7 @@
*/
@Test
public void testGetCategories_camera_and_videos() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// Create 1 video file in Camera dir to test
@@ -303,7 +308,7 @@
*/
@Test
public void testGetCategories_screenshots_and_favorites() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// Create 1 image file in Screenshots dir to test
@@ -327,7 +332,7 @@
*/
@Test
public void testGetCategories_downloads_and_favorites() throws Exception {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c.getCount()).isEqualTo(0);
// Create 1 image file in Screenshots dir to test
@@ -351,25 +356,21 @@
*/
@Test
public void testGetItems() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ null, /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create 1 image and 1 video file to test
// {@link ItemsProvider#getItems(String, int, int, String, UserId)}.
// Both files should be returned.
File imageFile = assertCreateNewImage();
File videoFile = assertCreateNewVideo();
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ null, /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ null, /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems + 2);
+ assertThat(res.getCount()).isEqualTo(2);
assertThatOnlyImagesVideos(res);
+ // Reset the cursor back. Cursor#moveToPosition(-1) will reset the position to -1,
+ // but since there is no such valid cursor position, it returns false.
+ assertThat(res.moveToPosition(-1)).isFalse();
assertThatAllImagesVideos(res.getCount());
} finally {
imageFile.delete();
@@ -377,17 +378,56 @@
}
}
+ @Test
+ public void testGetItems_sortOrder() throws Exception {
+ try {
+ final long timeNow = System.nanoTime() / 1000;
+ final Uri imageFileDateNowUri
+ = createFileAndGet(getDcimDir(), IMAGE_FILE_NAME, timeNow);
+ final Uri videoFileDateNowUri
+ = createFileAndGet(getCameraDir(), VIDEO_FILE_NAME, timeNow);
+ final Uri imageFileDateNowPlus1Uri = createFileAndGet(getDownloadsDir(),
+ "latest_" + IMAGE_FILE_NAME, timeNow + 1000);
+
+ // This is the list of uris based on the expected sort order of items returned by
+ // ItemsProvider#getItems
+ List<Uri> uris = new ArrayList<>();
+ // This is the latest image file
+ uris.add(imageFileDateNowPlus1Uri);
+ // Video file was scanned after image file, hence has higher _id than image file
+ uris.add(videoFileDateNowUri);
+ uris.add(imageFileDateNowUri);
+
+ try (Cursor cursor = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ null, /* userId */ null)) {
+ assertThat(cursor).isNotNull();
+
+ final int expectedCount = uris.size();
+ assertThat(cursor.getCount()).isEqualTo(expectedCount);
+
+ int rowNum = 0;
+ assertThat(cursor.moveToFirst()).isTrue();
+ final int idColumnIndex = cursor.getColumnIndexOrThrow(Item.ItemColumns.ID);
+ while (rowNum < expectedCount) {
+ assertWithMessage("id at row:" + rowNum + " is expected to be"
+ + " same as id in " + uris.get(rowNum))
+ .that(String.valueOf(cursor.getLong(idColumnIndex)))
+ .isEqualTo(uris.get(rowNum).getLastPathSegment());
+ cursor.moveToNext();
+ rowNum++;
+ }
+ }
+ } finally {
+ deleteAllFilesNoThrow();
+ }
+ }
+
/**
* Tests {@link {@link ItemsProvider#getItems(String, int, int, String, UserId)}} does not
* return hidden images/videos.
*/
@Test
public void testGetItems_nonMedia() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ null, /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create 1 image and 1 video file in a hidden dir to test
// {@link ItemsProvider#getItems(String, int, int, String, UserId)}.
// Both should not be returned.
@@ -395,12 +435,10 @@
File imageFileHidden = assertCreateNewImage(hiddenDir);
File videoFileHidden = assertCreateNewVideo(hiddenDir);
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ null, /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ null, /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ assertThat(res.getCount()).isEqualTo(0);
} finally {
imageFileHidden.delete();
videoFileHidden.delete();
@@ -414,23 +452,16 @@
*/
@Test
public void testGetItemsImages() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ "image/*", /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create 1 image and 1 video file to test
// {@link ItemsProvider#getItems(String, int, int, String, UserId)}.
// Only 1 should be returned.
File imageFile = assertCreateNewImage();
File videoFile = assertCreateNewVideo();
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ "image/*", /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ "image/*", /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems + 1);
+ assertThat(res.getCount()).isEqualTo(1);
assertThatOnlyImages(res);
assertThatAllImages(res.getCount());
@@ -446,20 +477,13 @@
*/
@Test
public void testGetItemsImages_png() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ "image/png", /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create a jpg file image. Tests negative use case, this should not be returned below.
File imageFile = assertCreateNewImage();
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ "image/png", /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ "image/png", /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ assertThat(res.getCount()).isEqualTo(0);
} finally {
imageFile.delete();
}
@@ -471,11 +495,6 @@
*/
@Test
public void testGetItemsImages_nonMedia() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ "image/*", /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create 1 image and 1 video file in a hidden dir to test
// {@link ItemsProvider#getItems(String, int, int, String)}.
// Both should not be returned.
@@ -483,12 +502,10 @@
File imageFileHidden = assertCreateNewImage(hiddenDir);
File videoFileHidden = assertCreateNewVideo(hiddenDir);
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ "image/*", /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ "image/*", /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ assertThat(res.getCount()).isEqualTo(0);
} finally {
imageFileHidden.delete();
videoFileHidden.delete();
@@ -502,23 +519,16 @@
*/
@Test
public void testGetItemsVideos() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ "video/*", /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create 1 image and 1 video file to test
// {@link ItemsProvider#getItems(String, int, int, String)}.
// Only 1 should be returned.
File imageFile = assertCreateNewImage();
File videoFile = assertCreateNewVideo();
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ "video/*", /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ "video/*", /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems + 1);
+ assertThat(res.getCount()).isEqualTo(1);
assertThatOnlyVideos(res);
assertThatAllVideos(res.getCount());
@@ -534,20 +544,13 @@
*/
@Test
public void testGetItemsVideos_mp4() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ "video/mp4", /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create a mp4 video file. Tests positive use case, this should be returned below.
File videoFile = assertCreateNewVideo();
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ "video/mp4", /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ "video/mp4", /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems + 1);
+ assertThat(res.getCount()).isEqualTo(1);
} finally {
videoFile.delete();
}
@@ -559,23 +562,16 @@
*/
@Test
public void testGetItemsVideos_nonMedia() throws Exception {
- Cursor res = sItemsProvider.getItems(/* category */ null, /* offset */ 0,
- /* limit */ -1, /* mimeType */ "video/*", /* userId */ null);
- assertThat(res).isNotNull();
- final int initialCountOfItems = res.getCount();
-
// Create 1 image and 1 video file in a hidden dir to test the API.
// Both should not be returned.
File hiddenDir = createHiddenDir();
File imageFileHidden = assertCreateNewImage(hiddenDir);
File videoFileHidden = assertCreateNewVideo(hiddenDir);
try {
- res = sItemsProvider.getItems(/* category */ null, /* offset */ 0, /* limit */ -1,
- /* mimeType */ "video/*", /* userId */ null);
+ final Cursor res = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+ /* limit */ -1, /* mimeType */ "video/*", /* userId */ null);
assertThat(res).isNotNull();
- final int laterCountOfItems = res.getCount();
-
- assertThat(laterCountOfItems).isEqualTo(initialCountOfItems);
+ assertThat(res.getCount()).isEqualTo(0);
} finally {
imageFileHidden.delete();
videoFileHidden.delete();
@@ -590,7 +586,7 @@
return;
}
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c).isNotNull();
assertThat(c.getCount()).isEqualTo(1);
@@ -612,15 +608,15 @@
}
private void assertCategoryUriIsValid(Uri uri) throws Exception {
- final AssetFileDescriptor fd1 = sIsolatedResolver.openTypedAssetFile(uri, "image/*", null,
+ final AssetFileDescriptor fd1 = mIsolatedResolver.openTypedAssetFile(uri, "image/*", null,
null);
assertThat(fd1).isNotNull();
- final ParcelFileDescriptor fd2 = sIsolatedResolver.openFileDescriptor(uri, "r");
+ final ParcelFileDescriptor fd2 = mIsolatedResolver.openFileDescriptor(uri, "r");
assertThat(fd2).isNotNull();
}
private void assertCategoriesNoMatch(String expectedCategoryName) {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
while (c != null && c.moveToNext()) {
final int nameColumnIndex = c.getColumnIndexOrThrow(Category.CategoryColumns.NAME);
final String categoryName = c.getString(nameColumnIndex);
@@ -630,7 +626,7 @@
private void assertGetCategoriesMatchMultiple(String category1, String category2,
int numberOfItems1, int numberOfItems2) {
- Cursor c = sItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
+ Cursor c = mItemsProvider.getCategories(/* mimeType */ null, /* userId */ null);
assertThat(c).isNotNull();
assertThat(c.getCount()).isEqualTo(2);
@@ -661,13 +657,13 @@
}
private void setIsFavorite(File file) {
- final Uri uri = MediaStore.scanFile(sIsolatedResolver, file);
+ final Uri uri = MediaStore.scanFile(mIsolatedResolver, file);
final ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.IS_FAVORITE, 1);
// Assert that 1 row corresponding to this file is updated.
- assertThat(sIsolatedResolver.update(uri, values, null)).isEqualTo(1);
+ assertThat(mIsolatedResolver.update(uri, values, null)).isEqualTo(1);
// Wait for MediaStore to be Idle to reduce flakes caused by database updates
- MediaStore.waitForIdle(sIsolatedResolver);
+ MediaStore.waitForIdle(mIsolatedResolver);
}
private void assertThatOnlyImagesVideos(Cursor c) throws Exception {
@@ -711,7 +707,7 @@
}
private int getCountOfMediaStoreImages() {
- try (Cursor c = sIsolatedResolver.query(
+ try (Cursor c = mIsolatedResolver.query(
MediaStore.Images.Media.getContentUri(VOLUME_EXTERNAL), null, null, null)) {
assertThat(c.moveToFirst()).isTrue();
return c.getCount();
@@ -719,7 +715,7 @@
}
private int getCountOfMediaStoreVideos() {
- try (Cursor c = sIsolatedResolver.query(
+ try (Cursor c = mIsolatedResolver.query(
MediaStore.Video.Media.getContentUri(VOLUME_EXTERNAL), null, null, null)) {
assertThat(c.moveToFirst()).isTrue();
return c.getCount();
@@ -750,7 +746,7 @@
final File file = new File(dir, fileName);
assertThat(file.createNewFile()).isTrue();
- MediaStore.scanFile(sIsolatedResolver, file);
+ MediaStore.scanFile(mIsolatedResolver, file);
return file;
}
@@ -786,8 +782,33 @@
File nomedia = new File(dir, ".nomedia");
nomedia.createNewFile();
- MediaStore.scanFile(sIsolatedResolver, nomedia);
+ MediaStore.scanFile(mIsolatedResolver, nomedia);
return dir;
}
+
+ private void deleteAllFilesNoThrow() {
+ try (Cursor c = mIsolatedResolver.query(
+ MediaStore.Files.getContentUri(VOLUME_EXTERNAL),
+ new String[] {MediaStore.MediaColumns.DATA}, null, null)) {
+ while(c.moveToNext()) {
+ (new File(c.getString(
+ c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)))).delete();
+ }
+ }
+
+ }
+
+ private Uri createFileAndGet(File parent, String fileName, long lastModifiedTime)
+ throws IOException {
+ final File file = new File(parent, fileName);
+ assertWithMessage("Create new file " + file)
+ .that(file.createNewFile()).isTrue();
+ assertWithMessage("Set last modified for " + file)
+ .that(file.setLastModified(lastModifiedTime)).isTrue();
+ final Uri uri = MediaStore.scanFile(mIsolatedResolver, file);
+ assertWithMessage("Uri obtained by scanning file " + file)
+ .that(uri).isNotNull();
+ return uri;
+ }
}
diff --git a/tests/src/com/android/providers/media/photopicker/LocalProvider.java b/tests/src/com/android/providers/media/photopicker/LocalProvider.java
index d18bc0b..5c5be01 100644
--- a/tests/src/com/android/providers/media/photopicker/LocalProvider.java
+++ b/tests/src/com/android/providers/media/photopicker/LocalProvider.java
@@ -16,7 +16,9 @@
package com.android.providers.media.photopicker;
+import static android.provider.CloudMediaProviderContract.EXTRA_GENERATION;
import static android.provider.CloudMediaProviderContract.MediaInfo;
+
import static com.android.providers.media.PickerProviderMediaGenerator.MediaGenerator;
import android.content.res.AssetFileDescriptor;
@@ -26,7 +28,7 @@
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.provider.CloudMediaProvider;
-import com.android.providers.media.MediaProvider;
+
import com.android.providers.media.PickerProviderMediaGenerator;
import java.io.FileNotFoundException;
@@ -84,6 +86,6 @@
}
private static long getGeneration(Bundle extras) {
- return extras == null ? 0 : extras.getLong(MediaInfo.MEDIA_GENERATION, 0);
+ return extras == null ? 0 : extras.getLong(EXTRA_GENERATION, 0);
}
}
diff --git a/tests/src/com/android/providers/media/photopicker/data/PickerResultTest.java b/tests/src/com/android/providers/media/photopicker/data/PickerResultTest.java
index ecf46cc..5c9b69c 100644
--- a/tests/src/com/android/providers/media/photopicker/data/PickerResultTest.java
+++ b/tests/src/com/android/providers/media/photopicker/data/PickerResultTest.java
@@ -36,8 +36,6 @@
import com.android.providers.media.PickerUriResolver;
import com.android.providers.media.photopicker.data.model.Item;
-import com.android.providers.media.photopicker.data.model.ItemTest;
-import com.android.providers.media.photopicker.data.model.UserId;
import org.junit.Before;
import org.junit.Test;
@@ -59,7 +57,7 @@
}
/**
- * Tests {@link PickerResult#getPickerResponseIntent(Context, List)} with single item
+ * Tests {@link PickerResult#getPickerResponseIntent(boolean, List)} with single item
* @throws Exception
*/
@Test
@@ -67,18 +65,28 @@
List<Item> items = null;
try {
items = createItemSelection(1);
- final Intent intent = PickerResult.getPickerResponseIntent(mContext, items);
+ final Uri expectedPickerUri = PickerResult.getPickerUri(items.get(0).getContentUri(),
+ items.get(0).getId());
+ final Intent intent = PickerResult.getPickerResponseIntent(
+ /* canSelectMultiple */ false, items);
final Uri result = intent.getData();
- assertPickerUri(result);
+ assertPickerUriFormat(result);
+ assertThat(result).isEqualTo(expectedPickerUri);
assertThat(mContext.getContentResolver().getType(result)).isEqualTo("image/jpeg");
+
+ final ClipData clipData = intent.getClipData();
+ assertThat(clipData).isNotNull();
+ final int count = clipData.getItemCount();
+ assertThat(count).isEqualTo(1);
+ assertThat(clipData.getItemAt(0).getUri()).isEqualTo(expectedPickerUri);
} finally {
deleteFiles(items);
}
}
/**
- * Tests {@link PickerResult#getPickerResponseIntent(Context, List)} with multiple items
+ * Tests {@link PickerResult#getPickerResponseIntent(boolean, List)} with multiple items
* @throws Exception
*/
@Test
@@ -87,20 +95,54 @@
try {
final int itemCount = 3;
items = createItemSelection(itemCount);
- final Intent intent = PickerResult.getPickerResponseIntent(mContext, items);
+ List<Uri> expectedPickerUris = new ArrayList<>();
+ for (Item item: items) {
+ expectedPickerUris.add(PickerResult.getPickerUri(item.getContentUri(),
+ item.getId()));
+ }
+ final Intent intent = PickerResult.getPickerResponseIntent(/* canSelectMultiple */ true,
+ items);
final ClipData clipData = intent.getClipData();
final int count = clipData.getItemCount();
assertThat(count).isEqualTo(itemCount);
for (int i = 0; i < count; i++) {
- assertPickerUri(clipData.getItemAt(i).getUri());
+ Uri uri = clipData.getItemAt(i).getUri();
+ assertPickerUriFormat(uri);
+ assertThat(uri).isEqualTo(expectedPickerUris.get(i));
}
} finally {
deleteFiles(items);
}
}
- private void assertPickerUri(Uri uri) {
+ /**
+ * Tests {@link PickerResult#getPickerResponseIntent(boolean, List)} when the user selected
+ * only one item in multi-select mode
+ * @throws Exception
+ */
+ @Test
+ public void testGetResultMultiple_onlyOneItemSelected() throws Exception {
+ ArrayList<Item> items = null;
+ try {
+ final int itemCount = 1;
+ items = createItemSelection(itemCount);
+ final Uri expectedPickerUri = PickerResult.getPickerUri(items.get(0).getContentUri(),
+ items.get(0).getId());
+ final Intent intent = PickerResult.getPickerResponseIntent(/* canSelectMultiple */ true,
+ items);
+
+ final ClipData clipData = intent.getClipData();
+ final int count = clipData.getItemCount();
+ assertThat(count).isEqualTo(itemCount);
+ assertPickerUriFormat(clipData.getItemAt(0).getUri());
+ assertThat(clipData.getItemAt(0).getUri()).isEqualTo(expectedPickerUri);
+ } finally {
+ deleteFiles(items);
+ }
+ }
+
+ private void assertPickerUriFormat(Uri uri) {
final String pickerUriPrefix = PickerUriResolver.PICKER_URI.toString();
assertThat(uri.toString().startsWith(pickerUriPrefix)).isTrue();
}
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java b/tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java
new file mode 100644
index 0000000..a9e42de
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/ActiveProfileButtonTest.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
+import static androidx.test.espresso.matcher.ViewMatchers.isSelected;
+import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withParent;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemNotSelected;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemSelected;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.clickItem;
+
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.not;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+import com.android.providers.media.photopicker.data.UserIdManager;
+import com.android.providers.media.photopicker.data.model.UserId;
+
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class ActiveProfileButtonTest extends PhotoPickerBaseTest {
+ private static final int PROFILE_BUTTON = R.id.profile_button;
+ private static final int ICON_THUMBNAIL_ID = R.id.icon_thumbnail;
+ private static final int ICON_CHECK_ID = R.id.icon_check;
+
+ @BeforeClass
+ public static void setupClass() throws Exception {
+ PhotoPickerBaseTest.setupClass();
+ PhotoPickerBaseTest.setUpActiveProfileButton();
+ }
+
+ @Rule
+ public ActivityScenarioRule<PhotoPickerTestActivity> mRule =
+ new ActivityScenarioRule<>(PhotoPickerBaseTest.getMultiSelectionIntent());
+
+ @Test
+ public void testProfileButton_hideInAlbumPhotos() throws Exception {
+ // Verify profile button is displayed
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed()));
+
+ // Goto Albums page
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .perform(click());
+ // Verify profile button is displayed
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed()));
+
+ // Navigate to photos in Camera album
+ onView(allOf(withText(R.string.picker_category_camera),
+ isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).perform(click());
+ // Verify profile button is not displayed
+ onView(withId(PROFILE_BUTTON)).check(matches(not(isDisplayed())));
+
+ // Click back button
+ onView(withContentDescription("Navigate up")).perform(click());
+
+ // on clicking back button we are back to Album grid
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .check(matches(isSelected()));
+ // Verify profile button is displayed
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed()));
+
+ // Goto Photos grid
+ onView(allOf(withText(PICKER_PHOTOS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .perform(click());
+ // Verify profile button is displayed
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed()));
+ }
+
+ @Test
+ public void testProfileButton_hideOnItemSelection() throws Exception {
+ // Verify profile button is displayed
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed()));
+
+ // position=1 is the first image item
+ final int position = 1;
+ // Select 1st item thumbnail and verify profile button is not shown
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+ assertItemSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+
+ onView(withId(PROFILE_BUTTON)).check(matches(not(isDisplayed())));
+
+ // Deselect the item to check profile button is shown
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+ assertItemNotSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed()));
+
+ // Select 1st item thumbnail and verify profile button is not shown
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+ assertItemSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+ onView(withId(PROFILE_BUTTON)).check(matches(not(isDisplayed())));
+
+ // Goto Albums page and verify profile button is not shown
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .perform(click());
+ onView(withId(PROFILE_BUTTON)).check(matches(not(isDisplayed())));
+ }
+
+ @Test
+ public void testProfileButton_doesNotShowErrorDialog() throws Exception {
+ // Verify profile button is displayed
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed()));
+ // Check the text on the button. It should be "Switch to work"
+ onView(withText(R.string.picker_work_profile)).check(matches(isDisplayed()));
+
+ // verify clicking it does not open error dialog
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed())).perform(click());
+ onView(withText(R.string.picker_profile_admin_title)).check(doesNotExist());
+ onView(withText(R.string.picker_profile_work_paused_title)).check(doesNotExist());
+
+ // Clicking the button, it takes a few ms to change the string.
+ // Wait 100ms to be sure.
+ // TODO(b/201982046): Replace with more stable workaround using Espresso idling resources
+ Thread.sleep(100);
+ onView(withText(R.string.picker_personal_profile)).check(matches(isDisplayed()));
+
+ // verify clicking it does not open error dialog
+ onView(withId(PROFILE_BUTTON)).check(matches(isDisplayed())).perform(click());
+ onView(withText(R.string.picker_profile_admin_title)).check(doesNotExist());
+ onView(withText(R.string.picker_profile_work_paused_title)).check(doesNotExist());
+
+ // Clicking the button, it takes a few ms to change the string.
+ // Wait 100ms to be sure.
+ // TODO(b/201982046): Replace with more stable workaround using Espresso idling resources
+ Thread.sleep(100);
+ onView(withText(R.string.picker_work_profile)).check(matches(isDisplayed()));
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java b/tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java
new file mode 100644
index 0000000..4b0ac8b
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/AlbumsTabTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withParent;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static com.android.providers.media.photopicker.espresso.RecyclerViewMatcher.withRecyclerView;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemDisplayed;
+
+import static org.hamcrest.Matchers.allOf;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class AlbumsTabTest extends PhotoPickerBaseTest {
+
+ // TODO(b/192304192): We need to use multi selection mode to go into full screen to check all
+ // the categories. Remove this when we can change BottomSheet behavior from test.
+ @Rule
+ public ActivityScenarioRule<PhotoPickerTestActivity> mRule =
+ new ActivityScenarioRule<>(PhotoPickerBaseTest.getMultiSelectionIntent());
+
+ @Test
+ public void testAlbumGrid() {
+ // Goto Albums page
+ onView(allOf(withText(R.string.picker_albums), withParent(withId(R.id.chip_container))))
+ .perform(click());
+
+ // Verify that toolbar has correct components
+ onView(withId(CHIP_CONTAINER_ID)).check(matches((isDisplayed())));
+ // Photos chip
+ onView(allOf(withText(PICKER_PHOTOS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .check(matches((isDisplayed())));
+ // Albums chip
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .check(matches((isDisplayed())));
+ // Navigate up button
+ onView(withContentDescription("Navigate up")).check(matches((isDisplayed())));
+
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ final int expectedAlbumCount = 3;
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID))
+ .check(new RecyclerViewItemCountAssertion(expectedAlbumCount));
+
+ // First album is Camera
+ assertItemContentInAlbumList(/* position */ 0, R.string.picker_category_camera);
+ // Second album is Videos
+ assertItemContentInAlbumList(/* position */ 1, R.string.picker_category_videos);
+ // Third album is Downloads
+ assertItemContentInAlbumList(/* position */ 2, R.string.picker_category_downloads);
+
+ // TODO(b/200513628): Check the bitmap of the album covers
+ }
+
+ private void assertItemContentInAlbumList(int position, int albumNameResId) {
+ // Verify the components are shown on the album item
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.album_name);
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.item_count);
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.icon_thumbnail);
+
+ // Verify we have the album in the list
+ onView(allOf(withText(albumNameResId), isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID))))
+ .check(matches(isDisplayed()));
+
+ // Verify the position of the album name matches the correct order
+ onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID)
+ .atPositionOnView(position, R.id.album_name))
+ .check(matches(withText(albumNameResId)));
+
+ // Verify the item count is correct
+ onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID)
+ .atPositionOnView(position, R.id.item_count))
+ .check(matches(withText("1 item")));
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java b/tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java
new file mode 100644
index 0000000..5ad647b
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/BlockedByAdminProfileButtonTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class BlockedByAdminProfileButtonTest extends PhotoPickerBaseTest {
+ @BeforeClass
+ public static void setupClass() throws Exception {
+ PhotoPickerBaseTest.setupClass();
+ PhotoPickerBaseTest.setUpBlockedByAdminProfileButton();
+ }
+
+ @Rule
+ public ActivityScenarioRule<PhotoPickerTestActivity> mRule =
+ new ActivityScenarioRule<>(PhotoPickerBaseTest.getMultiSelectionIntent());
+
+ @Test
+ public void testProfileButton_dialog() throws Exception {
+ final int profileButtonId = R.id.profile_button;
+ // Verify profile button is displayed
+ onView(withId(profileButtonId)).check(matches(isDisplayed()));
+ // Check the text on the button. It should be "Switch to personal"
+ onView(withText(R.string.picker_personal_profile)).check(matches(isDisplayed()));
+
+ // Verify onClick shows a dialog
+ onView(withId(profileButtonId)).check(matches(isDisplayed())).perform(click());
+ onView(withText(R.string.picker_profile_admin_title)).check(matches(isDisplayed()));
+ onView(withText(R.string.picker_profile_admin_msg_from_work)).check(matches(isDisplayed()));
+
+ onView(withText(android.R.string.ok)).check(matches(isDisplayed())).perform(click());
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java b/tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java
new file mode 100644
index 0000000..bf9b95a
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/MimeTypeFilterTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withParent;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static org.hamcrest.Matchers.allOf;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class MimeTypeFilterTest extends PhotoPickerBaseTest {
+
+ private static final String IMAGE_MIME_TYPE = "image/*";
+
+ @Rule
+ public ActivityScenarioRule<PhotoPickerTestActivity> mRule = new ActivityScenarioRule<>(
+ PhotoPickerBaseTest.getSingleSelectMimeTypeFilterIntent(IMAGE_MIME_TYPE));
+
+ @Test
+ public void testPhotosTabOnlyImageItems() {
+
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ // Two image items and one recent date header
+ final int expectedItemCount = 3;
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(
+ new RecyclerViewItemCountAssertion(expectedItemCount));
+
+ final int videoContainerId = R.id.video_container;
+ // No Video item
+ onView(allOf(withId(videoContainerId),
+ withParent(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(doesNotExist());
+ }
+
+ @Test
+ public void testAlbumsTabNoVideosAlbum() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ // Go to Albums tab
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .perform(click());
+
+ // Only two albums, Camera and Downloads
+ final int expectedItemCount = 2;
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(
+ new RecyclerViewItemCountAssertion(expectedItemCount));
+
+ final int cameraStringId = R.string.picker_category_camera;
+ // Camera album exists
+ onView(allOf(withText(cameraStringId),
+ isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(matches(isDisplayed()));
+
+ final int downloadsStringId = R.string.picker_category_downloads;
+ // Downloads album exists
+ onView(allOf(withText(downloadsStringId),
+ isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(matches(isDisplayed()));
+
+ final int itemCountId = R.id.item_count;
+ // No item count on album items if there is mime type filter
+ onView(allOf(withId(itemCountId),
+ withParent(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(doesNotExist());
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java b/tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java
new file mode 100644
index 0000000..804f535
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/MultiSelectTest.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.InstrumentationRegistry.getTargetContext;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemDisplayed;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemSelected;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemNotSelected;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.clickItem;
+
+import static org.hamcrest.Matchers.not;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+
+import org.junit.runner.RunWith;
+import org.junit.Rule;
+import org.junit.Test;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class MultiSelectTest extends PhotoPickerBaseTest {
+ private static final int ICON_THUMBNAIL_ID = R.id.icon_thumbnail;
+ private static final int ICON_CHECK_ID = R.id.icon_check;
+
+ @Rule
+ public ActivityScenarioRule<PhotoPickerTestActivity> mRule
+ = new ActivityScenarioRule<>(PhotoPickerBaseTest.getMultiSelectionIntent());
+
+ @Test
+ public void testMultiselect_selectIcon() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ // position=1 is the first image item
+ final int position = 1;
+ // Check select icon is visible
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.overlay_gradient);
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+
+ // Verify that select icon is not selected yet
+ assertItemNotSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+
+ // Select 1st item thumbnail and verify select icon is selected
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+ assertItemSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+
+ // Deselect the item to check item is marked as not selected.
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+ assertItemNotSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+
+ // Now, click on the select/check icon, verify we can also click on check icon to select or
+ // deselect an item.
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+ assertItemSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+
+ // Click on recyclerView item, this deselects the item. Verify that we can click on any
+ // region on the recyclerView item to select/deselect the item.
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, /* targetViewId */ -1);
+ assertItemNotSelected(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+ }
+
+ @Test
+ public void testMultiSelect_bottomBar() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ final int bottomBarId = R.id.picker_bottom_bar;
+ final int viewSelectedId = R.id.button_view_selected;
+ final int addButtonId = R.id.button_add;
+
+ // Initially, buttons should be hidden
+ onView(withId(bottomBarId)).check(matches(not(isDisplayed())));
+
+ // position=1 is the first image item
+ final int position = 1;
+ // Selecting one item shows view selected and add button
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+
+ onView(withId(bottomBarId)).check(matches(isDisplayed()));
+ onView(withId(viewSelectedId)).check(matches(isDisplayed()));
+ onView(withId(viewSelectedId)).check(matches(withText(R.string.picker_view_selected)));
+ onView(withId(addButtonId)).check(matches(isDisplayed()));
+
+ // When the selected item count is 0, ViewSelected and add button should hide
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+ onView(withId(bottomBarId)).check(matches(not(isDisplayed())));
+ onView(withId(viewSelectedId)).check(matches(not(isDisplayed())));
+ onView(withId(addButtonId)).check(matches(not(isDisplayed())));
+ }
+
+ @Test
+ public void testMultiSelect_addButtonText() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ final int addButtonId = R.id.button_add;
+ final String addButtonString =
+ getTargetContext().getResources().getString(R.string.add);
+
+ // Selecting one item will enable add button and show "Add (1)" as button text
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, /* position */ 1, ICON_THUMBNAIL_ID);
+
+ onView(withId(addButtonId)).check(matches(isDisplayed()));
+ onView(withId(addButtonId)).check(matches(withText(addButtonString + " (1)")));
+
+ // When the selected item count is 2, "Add (2)" should be displayed
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, /* position */ 2, ICON_THUMBNAIL_ID);
+ onView(withId(addButtonId)).check(matches(isDisplayed()));
+ onView(withId(addButtonId)).check(matches(withText(addButtonString + " (2)")));
+
+ // When the item is deselected add button resets to selected count
+ clickItem(PICKER_TAB_RECYCLERVIEW_ID, /* position */ 2, ICON_THUMBNAIL_ID);
+ onView(withId(addButtonId)).check(matches(isDisplayed()));
+ onView(withId(addButtonId)).check(matches(withText(addButtonString + " (1)")));
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java
new file mode 100644
index 0000000..0a13a5f
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerActivityTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.hasChildCount;
+import static androidx.test.espresso.matcher.ViewMatchers.isClickable;
+import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.isNotSelected;
+import static androidx.test.espresso.matcher.ViewMatchers.isSelected;
+import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withParent;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static com.android.providers.media.photopicker.espresso.RecyclerViewMatcher.withRecyclerView;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.hamcrest.Matchers.allOf;
+
+import android.app.Activity;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class PhotoPickerActivityTest extends PhotoPickerBaseTest {
+ @Rule
+ public ActivityScenarioRule<PhotoPickerTestActivity> mRule
+ = new ActivityScenarioRule<>(PhotoPickerBaseTest.getSingleSelectionIntent());
+
+ /**
+ * Simple test to check we are able to launch PhotoPickerActivity
+ */
+ @Test
+ public void testActivityLayout_Simple() {
+ onView(withId(R.id.toolbar)).check(matches(isDisplayed()));
+ onView(withId(R.id.fragment_container)).check(matches(isDisplayed()));
+ onView(withContentDescription("Navigate up")).perform(click());
+ assertThat(mRule.getScenario().getResult().getResultCode()).isEqualTo(
+ Activity.RESULT_CANCELED);
+ }
+
+ @Test
+ public void testToolbarLayout() {
+ onView(withId(R.id.toolbar)).check(matches(isDisplayed()));
+
+ onView(withId(CHIP_CONTAINER_ID)).check(matches(isDisplayed()));
+ onView(withId(CHIP_CONTAINER_ID)).check(matches(hasChildCount(2)));
+
+ onView(allOf(withText(PICKER_PHOTOS_STRING_ID),
+ isDescendantOfA(withId(CHIP_CONTAINER_ID)))).check(matches(isDisplayed()));
+ onView(allOf(withText(PICKER_PHOTOS_STRING_ID),
+ isDescendantOfA(withId(CHIP_CONTAINER_ID)))).check(matches(isClickable()));
+
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID),
+ isDescendantOfA(withId(CHIP_CONTAINER_ID)))).check(matches(isDisplayed()));
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID),
+ isDescendantOfA(withId(CHIP_CONTAINER_ID)))).check(matches(isClickable()));
+
+ // TODO(b/200513333): Check close icon
+ }
+
+ @Test
+ public void testTabChipNavigation() {
+ onView(withId(CHIP_CONTAINER_ID)).check(matches(isDisplayed()));
+
+ // On clicking albums tab, we should see albums tab
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .perform(click());
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .check(matches(isSelected()));
+ onView(allOf(withText(PICKER_PHOTOS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .check(matches(isNotSelected()));
+ // Verify Camera album is shown, we are in albums tab
+ onView(allOf(withText(R.string.picker_category_camera),
+ isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(matches(isDisplayed()));
+
+
+ // On clicking photos tab chip, we should see photos tab
+ onView(allOf(withText(PICKER_PHOTOS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .perform(click());
+ onView(allOf(withText(PICKER_PHOTOS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .check(matches(isSelected()));
+ onView(allOf(withText(PICKER_ALBUMS_STRING_ID), withParent(withId(CHIP_CONTAINER_ID))))
+ .check(matches(isNotSelected()));
+ // Verify first item is recent header, we are in photos tab
+ onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID)
+ .atPositionOnView(0, R.id.date_header_title))
+ .check(matches(withText(R.string.recent)));
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java
new file mode 100644
index 0000000..b7d1152
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerBaseTest.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.InstrumentationRegistry.getTargetContext;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.Manifest;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.system.ErrnoException;
+import android.system.Os;
+
+import androidx.annotation.NonNull;
+import androidx.core.util.Supplier;
+import androidx.test.InstrumentationRegistry;
+
+import com.android.providers.media.R;
+import com.android.providers.media.photopicker.data.UserIdManager;
+import com.android.providers.media.photopicker.data.model.UserId;
+import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
+
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.attribute.FileTime;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+public class PhotoPickerBaseTest {
+ protected static final int PICKER_TAB_RECYCLERVIEW_ID = R.id.picker_tab_recyclerview;
+ protected static final int CHIP_CONTAINER_ID = R.id.chip_container;
+ protected static final int PICKER_PHOTOS_STRING_ID = R.string.picker_photos;
+ protected static final int PICKER_ALBUMS_STRING_ID = R.string.picker_albums;
+
+ private static final Intent sSingleSelectIntent;
+ static {
+ sSingleSelectIntent = new Intent(MediaStore.ACTION_PICK_IMAGES);
+ sSingleSelectIntent.addCategory(Intent.CATEGORY_FRAMEWORK_INSTRUMENTATION_TEST);
+ }
+
+ private static final Intent sMultiSelectionIntent;
+ static {
+ sMultiSelectionIntent = new Intent(MediaStore.ACTION_PICK_IMAGES);
+ Bundle extras = new Bundle();
+ extras.putInt(MediaStore.EXTRA_PICK_IMAGES_MAX, MediaStore.getPickImagesMaxLimit());
+ sMultiSelectionIntent.addCategory(Intent.CATEGORY_FRAMEWORK_INSTRUMENTATION_TEST);
+ sMultiSelectionIntent.putExtras(extras);
+ }
+
+ private static final File IMAGE_FILE = new File(Environment.getExternalStorageDirectory(),
+ Environment.DIRECTORY_DCIM + "/Camera"
+ + "/image_" + System.currentTimeMillis() + ".jpeg");
+ private static final File GIF_FILE = new File(Environment.getExternalStorageDirectory(),
+ Environment.DIRECTORY_DOWNLOADS + "/gif_" + System.currentTimeMillis() + ".gif");
+ private static final File VIDEO_FILE = new File(Environment.getExternalStorageDirectory(),
+ Environment.DIRECTORY_MOVIES + "/video_" + System.currentTimeMillis() + ".mp4");
+
+ private static final long POLLING_TIMEOUT_MILLIS_LONG = TimeUnit.SECONDS.toMillis(2);
+ private static final long POLLING_SLEEP_MILLIS = 200;
+
+ private static IsolatedContext sIsolatedContext;
+ private static UserIdManager sUserIdManager;
+
+ public static Intent getSingleSelectMimeTypeFilterIntent(String mimeTypeFilter) {
+ final Intent intent = new Intent(sSingleSelectIntent);
+ intent.setType(mimeTypeFilter);
+ return intent;
+ }
+
+ public static Intent getSingleSelectionIntent() {
+ return sSingleSelectIntent;
+ }
+
+ public static Intent getMultiSelectionIntent() {
+ return sMultiSelectionIntent;
+ }
+
+ public static IsolatedContext getIsolatedContext() {
+ return sIsolatedContext;
+ }
+
+ public static UserIdManager getMockUserIdManager() {
+ return sUserIdManager;
+ }
+
+ @BeforeClass
+ public static void setupClass() throws Exception {
+ MediaStore.waitForIdle(getTargetContext().getContentResolver());
+ pollForCondition(() -> isExternalStorageStateMounted(), "Timed out while"
+ + " waiting for ExternalStorageState to be MEDIA_MOUNTED");
+
+ InstrumentationRegistry.getInstrumentation().getUiAutomation()
+ .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE,
+ Manifest.permission.READ_COMPAT_CHANGE_CONFIG,
+ Manifest.permission.INTERACT_ACROSS_USERS);
+
+ sIsolatedContext = new IsolatedContext(getTargetContext(), "modern",
+ /* asFuseThread */ false);
+
+ sUserIdManager = mock(UserIdManager.class);
+ when(sUserIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER);
+
+ createFiles();
+ }
+
+ @AfterClass
+ public static void destroyClass() {
+ IMAGE_FILE.delete();
+ GIF_FILE.delete();
+ VIDEO_FILE.delete();
+
+ InstrumentationRegistry.getInstrumentation()
+ .getUiAutomation().dropShellPermissionIdentity();
+ }
+
+ private static void createFiles() throws Exception {
+ long timeNow = System.currentTimeMillis();
+ // Create files and change dateModified so that we can predict the recyclerView item
+ // position
+ createFile(IMAGE_FILE, timeNow + 2000);
+ createFile(GIF_FILE, timeNow + 1000);
+ createFile(VIDEO_FILE, timeNow);
+ }
+
+ private static void pollForCondition(Supplier<Boolean> condition, String errorMessage)
+ throws Exception {
+ for (int i = 0; i < POLLING_TIMEOUT_MILLIS_LONG / POLLING_SLEEP_MILLIS; i++) {
+ if (condition.get()) {
+ return;
+ }
+ Thread.sleep(POLLING_SLEEP_MILLIS);
+ }
+ throw new TimeoutException(errorMessage);
+ }
+
+ private static boolean isExternalStorageStateMounted() {
+ final File target = Environment.getExternalStorageDirectory();
+ try {
+ return (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState(target))
+ && Os.statvfs(target.getAbsolutePath()).f_blocks > 0);
+ } catch (ErrnoException ignored) {
+ }
+ return false;
+ }
+
+ private static void createFile(File file, long dateModified) throws IOException {
+ File parentFile = file.getParentFile();
+ parentFile.mkdirs();
+
+ assertThat(parentFile.exists()).isTrue();
+ assertThat(file.createNewFile()).isTrue();
+
+ // Change dateModified so that we can predict the recyclerView item position
+ Files.setLastModifiedTime(file.toPath(), FileTime.fromMillis(dateModified));
+
+ final Uri uri = MediaStore.scanFile(getIsolatedContext().getContentResolver(), file);
+ assertThat(uri).isNotNull();
+
+ }
+
+ /**
+ * Mock UserIdManager class such that the profile button is active and the user is in personal
+ * profile.
+ */
+ static void setUpActiveProfileButton() {
+ when(sUserIdManager.isMultiUserProfiles()).thenReturn(true);
+ when(sUserIdManager.isBlockedByAdmin()).thenReturn(false);
+ when(sUserIdManager.isWorkProfileOff()).thenReturn(false);
+ when(sUserIdManager.isCrossProfileAllowed()).thenReturn(true);
+ when(sUserIdManager.isManagedUserSelected()).thenReturn(false);
+
+ // setPersonalAsCurrentUserProfile() is called onClick of Active Profile Button to change
+ // profiles
+ doAnswer(invocation -> {
+ updateIsManagedUserSelected(/* isManagedUserSelected */ false);
+ return null;
+ }).when(sUserIdManager).setPersonalAsCurrentUserProfile();
+
+ // setManagedAsCurrentUserProfile() is called onClick of Active Profile Button to change
+ // profiles
+ doAnswer(invocation -> {
+ updateIsManagedUserSelected(/* isManagedUserSelected */ true);
+ return null;
+ }).when(sUserIdManager).setManagedAsCurrentUserProfile();
+ }
+
+ /**
+ * Mock UserIdManager class such that the user is in personal profile and work apps are
+ * turned off
+ */
+ static void setUpWorkAppsOffProfileButton() {
+ when(sUserIdManager.isMultiUserProfiles()).thenReturn(true);
+ when(sUserIdManager.isBlockedByAdmin()).thenReturn(false);
+ when(sUserIdManager.isWorkProfileOff()).thenReturn(true);
+ when(sUserIdManager.isCrossProfileAllowed()).thenReturn(false);
+ when(sUserIdManager.isManagedUserSelected()).thenReturn(false);
+ }
+
+ /**
+ * Mock UserIdManager class such that the user is in work profile and accessing personal
+ * profile content is blocked by admin
+ */
+ static void setUpBlockedByAdminProfileButton() {
+ when(sUserIdManager.isMultiUserProfiles()).thenReturn(true);
+ when(sUserIdManager.isBlockedByAdmin()).thenReturn(true);
+ when(sUserIdManager.isWorkProfileOff()).thenReturn(false);
+ when(sUserIdManager.isCrossProfileAllowed()).thenReturn(false);
+ when(sUserIdManager.isManagedUserSelected()).thenReturn(true);
+ }
+
+ private static void updateIsManagedUserSelected(boolean isManagedUserSelected) {
+ when(sUserIdManager.isManagedUserSelected()).thenReturn(isManagedUserSelected);
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java
new file mode 100644
index 0000000..55dcd99
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotoPickerTestActivity.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import com.android.providers.media.photopicker.PhotoPickerActivity;
+import com.android.providers.media.photopicker.data.ItemsProvider;
+import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
+
+public class PhotoPickerTestActivity extends PhotoPickerActivity {
+ @Override
+ protected PickerViewModel createViewModel() {
+ PickerViewModel pickerViewModel = super.createViewModel();
+ pickerViewModel.setItemsProvider(new ItemsProvider(
+ PhotoPickerBaseTest.getIsolatedContext()));
+ pickerViewModel.setUserIdManager(PhotoPickerBaseTest.getMockUserIdManager());
+ return pickerViewModel;
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java b/tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java
new file mode 100644
index 0000000..16a01eb
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/PhotosTabTest.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.InstrumentationRegistry.getTargetContext;
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.isSelected;
+import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withParent;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static com.android.providers.media.photopicker.espresso.RecyclerViewMatcher.withRecyclerView;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemDisplayed;
+import static com.android.providers.media.photopicker.espresso.RecyclerViewTestUtils.assertItemNotDisplayed;
+
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+import com.android.providers.media.photopicker.util.DateTimeUtils;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class PhotosTabTest extends PhotoPickerBaseTest {
+ private static final int ICON_THUMBNAIL_ID = R.id.icon_thumbnail;
+ private static final int ICON_GIF_ID = R.id.icon_gif;
+ private static final int VIDEO_CONTAINER_ID = R.id.video_container;
+ private static final int ICON_CHECK_ID = R.id.icon_check;
+ private static final int OVERLAY_GRADIENT_ID = R.id.overlay_gradient;
+
+ @Rule
+ public final ActivityScenarioRule<PhotoPickerTestActivity> mRule
+ = new ActivityScenarioRule<>(PhotoPickerBaseTest.getSingleSelectionIntent());
+
+ @Test
+ public void testPhotoGridLayout_photoGrid() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ // check the count of items
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(new RecyclerViewItemCountAssertion(4));
+
+ // Verify first item is recent header
+ onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID)
+ .atPositionOnView(0, R.id.date_header_title))
+ .check(matches(withText(R.string.recent)));
+
+ // Verify bottom bar is not displayed
+ onView(withId(R.id.picker_bottom_bar)).check(matches(not(isDisplayed())));
+ }
+
+ @Test
+ public void testPhotoGridLayout_image() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ // Verify second item in the recycler view is image
+ final int position = 1;
+ // Verify we have the thumbnail
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+
+ // Verify check icon, gif icon and video icon are not displayed
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, OVERLAY_GRADIENT_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_GIF_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, VIDEO_CONTAINER_ID);
+ }
+
+ @Test
+ public void testPhotoGridLayout_gif() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ // Verify third item in the recycler view is video
+ final int position = 2;
+ // Verify we have the thumbnail
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+ // Verify gif icon is displayed
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, OVERLAY_GRADIENT_ID);
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_GIF_ID);
+
+ // Verify check icon and video icon are not displayed
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, VIDEO_CONTAINER_ID);
+ }
+
+ @Test
+ public void testPhotoGridLayout_video() {
+ onView(withId(PICKER_TAB_RECYCLERVIEW_ID)).check(matches(isDisplayed()));
+
+ // Verify fourth item in the recycler view is video
+ final int position = 3;
+ // Verify we have the thumbnail
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_THUMBNAIL_ID);
+
+ // Verify video icon and duration are displayed
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, OVERLAY_GRADIENT_ID);
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, VIDEO_CONTAINER_ID);
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.video_duration);
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, R.id.icon_video);
+ onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID)
+ .atPositionOnView(position, R.id.video_duration))
+ .check(matches(withText(containsString("0"))));
+
+ // Verify check icon and gif icon are not displayed
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_CHECK_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, position, ICON_GIF_ID);
+ }
+
+ @Test
+ public void testPhotoGrid_albumPhotos() {
+ final int chipContainerId = R.id.chip_container;
+ // Navigate to Albums tab
+ onView(allOf(withText(R.string.picker_albums), withParent(withId(chipContainerId))))
+ .perform(click());
+
+ // Navigate to photos in Camera album
+ onView(allOf(withText(R.string.picker_category_camera),
+ isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).perform(click());
+
+ final int dateHeaderTitleId = R.id.date_header_title;
+ final int recentHeaderPosition = 0;
+ // Verify that first item is not a recent header
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, recentHeaderPosition, dateHeaderTitleId);
+ onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID)
+ .atPositionOnView(recentHeaderPosition, dateHeaderTitleId))
+ .check(matches(not(withText(R.string.recent))));
+
+ // Verify that first item is TODAY
+ onView(withRecyclerView(PICKER_TAB_RECYCLERVIEW_ID)
+ .atPositionOnView(0, dateHeaderTitleId))
+ .check(matches(withText(DateTimeUtils.getDateTimeString(getTargetContext(),
+ System.currentTimeMillis()))));
+
+ final int photoItemPosition = 1;
+ // Verify first item is image and has no other icons other than thumbnail
+ assertItemDisplayed(PICKER_TAB_RECYCLERVIEW_ID, photoItemPosition, ICON_THUMBNAIL_ID);
+
+ // Verify check icon, gif icon and video icon are not displayed
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, photoItemPosition, OVERLAY_GRADIENT_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, photoItemPosition, ICON_CHECK_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, photoItemPosition, ICON_GIF_ID);
+ assertItemNotDisplayed(PICKER_TAB_RECYCLERVIEW_ID, photoItemPosition, VIDEO_CONTAINER_ID);
+
+ // Verify that toolbar has the title as category name
+ onView(allOf(withText(R.string.picker_category_camera),
+ withParent(withId(R.id.toolbar))))
+ .check(matches(isDisplayed()));
+
+ // Verify that tab chips are not shown on the toolbar
+ onView(withId(chipContainerId)).check(matches(not(isDisplayed())));
+
+ // Click back button
+ onView(withContentDescription("Navigate up")).perform(click());
+
+ // on clicking back button we are back to Album grid
+ onView(allOf(withText(R.string.picker_albums), withParent(withId(chipContainerId))))
+ .check(matches(isSelected()));
+ onView(allOf(withText(R.string.picker_category_camera),
+ isDescendantOfA(withId(PICKER_TAB_RECYCLERVIEW_ID)))).check(matches(isDisplayed()));
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewItemCountAssertion.java b/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewItemCountAssertion.java
new file mode 100644
index 0000000..ded61c7
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewItemCountAssertion.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.View;
+
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.test.espresso.NoMatchingViewException;
+import androidx.test.espresso.ViewAssertion;
+
+/**
+ * A {@link ViewAssertion} that asserts for item count of {@link RecyclerView}
+ * Shamelessly borrowed from various codebase.
+ */
+class RecyclerViewItemCountAssertion implements ViewAssertion {
+ private final int mExpectedCount;
+
+ public RecyclerViewItemCountAssertion(int expectedCount) {
+ mExpectedCount = expectedCount;
+ }
+
+ @Override
+ public void check(View view, NoMatchingViewException noMatchingViewException) {
+ if (noMatchingViewException != null) {
+ throw noMatchingViewException;
+ }
+
+ RecyclerView.Adapter adapter = ((RecyclerView) view).getAdapter();
+ assertThat(adapter).isNotNull();
+ assertThat(adapter.getItemCount()).isEqualTo(mExpectedCount);
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewMatcher.java b/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewMatcher.java
new file mode 100644
index 0000000..55c3ef0
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewMatcher.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+/**
+ * A {@link org.hamcrest.Matcher} that checks the given View is assignable from
+ * {@link RecyclerView}
+ * <p>
+ * Shamelessly borrowed from various codebase.
+ */
+class RecyclerViewMatcher {
+ private final int mRecyclerViewId;
+
+ RecyclerViewMatcher(int recyclerViewId) {
+ mRecyclerViewId = recyclerViewId;
+ }
+
+ public static RecyclerViewMatcher withRecyclerView(int recyclerViewId) {
+ return new RecyclerViewMatcher(recyclerViewId);
+ }
+
+ public Matcher<View> atPositionOnView(int position, int targetViewId) {
+ return new TypeSafeMatcher<View>() {
+ @Nullable
+ private View mRecyclerViewChildView;
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("is assignable from class: " + RecyclerView.class
+ + ", at given position: " + position
+ + ", with given targetViewId: " + targetViewId);
+ }
+
+ @Override
+ public boolean matchesSafely(View view) {
+ if (mRecyclerViewChildView == null) {
+ RecyclerView recyclerView =
+ view.getRootView().findViewById(mRecyclerViewId);
+ if (recyclerView == null) {
+ // No RecyclerView with given Id, hence no match
+ return false;
+ }
+
+ RecyclerView.ViewHolder viewHolder =
+ recyclerView.findViewHolderForAdapterPosition(position);
+ if (viewHolder == null) {
+ // No viewHolder, hence no match
+ return false;
+ }
+
+ // Get itemView at given position from RecyclerView ViewHolder
+ mRecyclerViewChildView = viewHolder.itemView;
+ }
+
+ if (targetViewId == -1) {
+ return view == mRecyclerViewChildView;
+ } else {
+ // Returns specific view in the given RecyclerView item
+ View targetView = mRecyclerViewChildView.findViewById(targetViewId);
+ return view == targetView;
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewTestUtils.java b/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewTestUtils.java
new file mode 100644
index 0000000..183f629
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/RecyclerViewTestUtils.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.isNotSelected;
+import static androidx.test.espresso.matcher.ViewMatchers.isSelected;
+
+import static com.android.providers.media.photopicker.espresso.RecyclerViewMatcher.withRecyclerView;
+
+import static org.hamcrest.Matchers.not;
+
+class RecyclerViewTestUtils {
+ public static void assertItemDisplayed(int recyclerViewId, int position, int targetViewId) {
+ onView(withRecyclerView(recyclerViewId)
+ .atPositionOnView(position, targetViewId))
+ .check(matches(isDisplayed()));
+ }
+
+ public static void assertItemNotDisplayed(int recyclerViewId, int position, int targetViewId) {
+ onView(withRecyclerView(recyclerViewId)
+ .atPositionOnView(position, targetViewId))
+ .check(matches(not(isDisplayed())));
+ }
+
+ public static void assertItemSelected(int recyclerViewId, int position, int targetViewId) {
+ onView(withRecyclerView(recyclerViewId)
+ .atPositionOnView(position, targetViewId))
+ .check(matches(isSelected()));
+ }
+
+ public static void assertItemNotSelected(int recyclerViewId, int position, int targetViewId) {
+ onView(withRecyclerView(recyclerViewId)
+ .atPositionOnView(position, targetViewId))
+ .check(matches(isNotSelected()));
+ }
+
+ public static void clickItem(int recyclerViewId, int position, int targetViewId) {
+ onView(withRecyclerView(recyclerViewId)
+ .atPositionOnView(position, targetViewId))
+ .perform(click());
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java b/tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java
new file mode 100644
index 0000000..90c2cad
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/espresso/WorkAppsOffProfileButtonTest.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.photopicker.espresso;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import com.android.providers.media.R;
+
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class WorkAppsOffProfileButtonTest extends PhotoPickerBaseTest {
+ @BeforeClass
+ public static void setupClass() throws Exception {
+ PhotoPickerBaseTest.setupClass();
+ PhotoPickerBaseTest.setUpWorkAppsOffProfileButton();
+ }
+
+ @Rule
+ public ActivityScenarioRule<PhotoPickerTestActivity> mRule =
+ new ActivityScenarioRule<>(PhotoPickerBaseTest.getMultiSelectionIntent());
+
+ @Test
+ public void testProfileButton_dialog() throws Exception {
+ final int profileButtonId = R.id.profile_button;
+ // Verify profile button is displayed
+ onView(withId(profileButtonId)).check(matches(isDisplayed()));
+ // Check the text on the button. It should be "Switch to work"
+ onView(withText(R.string.picker_work_profile)).check(matches(isDisplayed()));
+
+ // Verify onClick shows a dialog
+ onView(withId(profileButtonId)).check(matches(isDisplayed())).perform(click());
+ onView(withText(R.string.picker_profile_work_paused_title)).check(matches(isDisplayed()));
+ onView(withText(R.string.picker_profile_work_paused_msg)).check(matches(isDisplayed()));
+ onView(withText(android.R.string.ok)).check(matches(isDisplayed())).perform(click());
+ }
+}
diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java
index 8d76e57..b660060 100644
--- a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java
+++ b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java
@@ -329,34 +329,100 @@
}
@Test
- public void testParseValuesFromIntent_allowMultiple() throws Exception {
+ public void testParseValuesFromIntent_allowMultipleNotSupported() throws Exception {
final Intent intent = new Intent();
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
mPickerViewModel.parseValuesFromIntent(intent);
+ assertThat(mPickerViewModel.canSelectMultiple()).isFalse();
+ }
+
+ @Test
+ public void testParseValuesFromIntent_setDefaultFalseForAllowMultiple() throws Exception {
+ final Intent intent = new Intent();
+
+ mPickerViewModel.parseValuesFromIntent(intent);
+
+ assertThat(mPickerViewModel.canSelectMultiple()).isFalse();
+ }
+
+ @Test
+ public void testParseValuesFromIntent_validMaxSelectionLimit() throws Exception {
+ final int maxLimit = MediaStore.getPickImagesMaxLimit() - 1;
+ final Intent intent = new Intent();
+ intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxLimit);
+
+ mPickerViewModel.parseValuesFromIntent(intent);
+
assertThat(mPickerViewModel.canSelectMultiple()).isTrue();
+ assertThat(mPickerViewModel.getMaxSelectionLimit()).isEqualTo(maxLimit);
}
@Test
- public void testParseValuesFromIntent_noAllowMultiple()
+ public void testParseValuesFromIntent_negativeMaxSelectionLimit_throwsException()
throws Exception {
final Intent intent = new Intent();
- intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false);
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+ intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, -1);
+
+ try {
+ mPickerViewModel.parseValuesFromIntent(intent);
+ fail("The maximum selection limit is not allowed to be negative");
+ } catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void testParseValuesFromIntent_tooLargeMaxSelectionLimit_throwsException()
+ throws Exception {
+ final Intent intent = new Intent();
+ intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MediaStore.getPickImagesMaxLimit() + 1);
+
+ try {
+ mPickerViewModel.parseValuesFromIntent(intent);
+ fail("The maximum selection limit should not be greater than "
+ + "MediaStore.getPickImagesMaxLimit()");
+ } catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void testParseValuesFromIntent_actionGetContent() throws Exception {
+ final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
mPickerViewModel.parseValuesFromIntent(intent);
- assertThat(mPickerViewModel.canSelectMultiple()).isFalse();
+ assertThat(mPickerViewModel.canSelectMultiple()).isTrue();
+ assertThat(mPickerViewModel.getMaxSelectionLimit())
+ .isEqualTo(MediaStore.getPickImagesMaxLimit());
}
@Test
- public void testParseValuesFromIntent_setDefaultFalseForAllowMultiple()
+ public void testParseValuesFromIntent_actionGetContent_doesNotRespectExtraPickImagesMax()
+ throws Exception {
+ final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
+ intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, 5);
+
+ try {
+ mPickerViewModel.parseValuesFromIntent(intent);
+ fail("EXTRA_PICK_IMAGES_MAX is not supported for ACTION_GET_CONTENT");
+ } catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void testParseValuesFromIntent_noMimeType_defaultFalse()
throws Exception {
final Intent intent = new Intent();
mPickerViewModel.parseValuesFromIntent(intent);
- assertThat(mPickerViewModel.canSelectMultiple()).isFalse();
+ assertThat(mPickerViewModel.hasMimeTypeFilter()).isFalse();
}
@Test
@@ -382,70 +448,6 @@
}
@Test
- public void testParseValuesFromIntent_noMimeType_defaultFalse()
- throws Exception {
- final Intent intent = new Intent();
-
- mPickerViewModel.parseValuesFromIntent(intent);
-
- assertThat(mPickerViewModel.hasMimeTypeFilter()).isFalse();
- }
-
- @Test
- public void testParseValuesFromIntent_noAllowMultiple_defaultLimit()
- throws Exception {
- final int maxLimit = 20;
- final Intent intent = new Intent();
- intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxLimit);
-
- mPickerViewModel.parseValuesFromIntent(intent);
-
- assertThat(mPickerViewModel.canSelectMultiple()).isFalse();
- assertThat(mPickerViewModel.getMaxSelectionLimit()).isNotEqualTo(maxLimit);
- }
-
- @Test
- public void testParseValuesFromIntent_validMaxSelectionLimit() throws Exception {
- final int maxLimit = 20;
- final Intent intent = new Intent();
- intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
- intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxLimit);
-
- mPickerViewModel.parseValuesFromIntent(intent);
-
- assertThat(mPickerViewModel.getMaxSelectionLimit()).isEqualTo(maxLimit);
- }
-
- @Test
- public void testParseValuesFromIntent_negativeMaxSelectionLimit_throwsException()
- throws Exception {
- final int maxLimit = -1;
- final Intent intent = new Intent();
- intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
- intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxLimit);
-
- try {
- mPickerViewModel.parseValuesFromIntent(intent);
- fail("The maximum selection limit is not allowed to be negative");
- } catch (Exception expected) {
- // expected
- }
- }
-
- @Test
- public void testParseValuesFromIntent_tooLargeMaxSelectionLimit_defaultValue()
- throws Exception {
- final int maxLimit = 10000;
- final Intent intent = new Intent();
- intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
- intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxLimit);
-
- mPickerViewModel.parseValuesFromIntent(intent);
-
- assertThat(mPickerViewModel.getMaxSelectionLimit()).isNotEqualTo(maxLimit);
- }
-
- @Test
public void testIsSelectionAllowed_exceedsMaxSelectionLimit_selectionNotAllowed()
throws Exception {
final int maxLimit = 2;
diff --git a/tests/src/com/android/providers/media/util/SyntheticPathUtilsTest.java b/tests/src/com/android/providers/media/util/SyntheticPathUtilsTest.java
new file mode 100644
index 0000000..b913114
--- /dev/null
+++ b/tests/src/com/android/providers/media/util/SyntheticPathUtilsTest.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.media.util;
+
+import static com.android.providers.media.util.SyntheticPathUtils.extractSyntheticRelativePathSegements;
+import static com.android.providers.media.util.SyntheticPathUtils.getPickerRelativePath;
+import static com.android.providers.media.util.SyntheticPathUtils.getRedactedRelativePath;
+import static com.android.providers.media.util.SyntheticPathUtils.getSyntheticRelativePath;
+import static com.android.providers.media.util.SyntheticPathUtils.isPickerPath;
+import static com.android.providers.media.util.SyntheticPathUtils.isRedactedPath;
+import static com.android.providers.media.util.SyntheticPathUtils.isSyntheticPath;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.runner.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SyntheticPathUtilsTest {
+ // Needs to match the redacted ids specification in SyntheticPathUtilsTest#REDACTED_URI_ID_SIZE
+ private static final String REDACTED_ID = "ruidaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ @Test
+ public void testGetSyntheticRelativePath() throws Exception {
+ assertThat(getSyntheticRelativePath()).isEqualTo(".transforms/synthetic");
+ }
+
+ @Test
+ public void testGetRedactedRelativePath() throws Exception {
+ assertThat(getRedactedRelativePath()).isEqualTo(".transforms/synthetic/redacted");
+ }
+
+ @Test
+ public void testGetPickerRelativePath() throws Exception {
+ assertThat(getPickerRelativePath()).isEqualTo(".transforms/synthetic/picker");
+ }
+
+ @Test
+ public void testIsSyntheticPath() throws Exception {
+ assertThat(isSyntheticPath("/storage/emulated/0/.transforms/synthetic", /* userId */ 0))
+ .isTrue();
+ assertThat(isSyntheticPath("/storage/emulated/10/.transforms/synthetic", /* userId */ 10))
+ .isTrue();
+ assertThat(isSyntheticPath("/storage/emulated/0/.transforms/SYNTHETIC/",/* userId */ 0))
+ .isTrue();
+
+ assertThat(isSyntheticPath("/storage/emulated/0/.transforms/synthetic", /* userId */ 10))
+ .isFalse();
+ assertThat(isSyntheticPath("/storage/emulated/10/.transforms/synthetic", /* userId */ 0))
+ .isFalse();
+ assertThat(isSyntheticPath("/storage/emulated/0/.transforms", /* userId */ 0)).isFalse();
+ assertThat(isSyntheticPath("/storage/emulated/0/synthetic", /* userId */ 0)).isFalse();
+ }
+
+ @Test
+ public void testIsRedactedPath() throws Exception {
+ assertThat(isRedactedPath("/storage/emulated/0/.transforms/synthetic/redacted/"
+ + REDACTED_ID, /* userId */ 0)).isTrue();
+ assertThat(isRedactedPath("/storage/emulated/10/.transforms/synthetic/redacted/"
+ + REDACTED_ID, /* userId */ 10)).isTrue();
+ assertThat(isRedactedPath("/storage/emulated/0/.transforms/synthetic/REDACTED/"
+ + REDACTED_ID, /* userId */ 0)).isTrue();
+
+ assertThat(isRedactedPath("/storage/emulated/0/.transforms/synthetic/redacted/"
+ + REDACTED_ID, /* userId */ 10)).isFalse();
+ assertThat(isRedactedPath("/storage/emulated/10/.transforms/synthetic/redacted/"
+ + REDACTED_ID, /* userId */ 0)).isFalse();
+ assertThat(isRedactedPath("/storage/emulated/0/.transforms/synthetic/picker/"
+ + REDACTED_ID, /* userId */ 0)).isFalse();
+ assertThat(isRedactedPath("/storage/emulated/0/.transforms/redacted/" + REDACTED_ID,
+ /* userId */ 0)).isFalse();
+ assertThat(isRedactedPath("/storage/emulated/0/synthetic/redacted/" + REDACTED_ID,
+ /* userId */ 0)).isFalse();
+ }
+
+ @Test
+ public void testIsPickerPath() throws Exception {
+ assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/picker/foo",
+ /* userId */ 0)).isTrue();
+ assertThat(isPickerPath("/storage/emulated/10/.transforms/synthetic/picker/foo",
+ /* userId */ 10)).isTrue();
+ assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/PICKER/bar/baz",
+ /* userId */ 0)).isTrue();
+
+ assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/picker/foo",
+ /* userId */ 10)).isFalse();
+ assertThat(isPickerPath("/storage/emulated/10/.transforms/synthetic/picker/foo",
+ /* userId */ 0)).isFalse();
+ assertThat(isPickerPath("/storage/emulated/0/.transforms/synthetic/redacted/foo",
+ /* userId */ 0)).isFalse();
+ assertThat(isPickerPath("/storage/emulated/0/.transforms/picker/foo", /* userId */ 0))
+ .isFalse();
+ assertThat(isPickerPath("/storage/emulated/0/synthetic/picker/foo", /* userId */ 0))
+ .isFalse();
+ }
+
+ @Test
+ public void testExtractSyntheticRelativePathSegments() throws Exception {
+ assertThat(extractSyntheticRelativePathSegements(
+ "/storage/emulated/10/.transforms/synthetic/picker",
+ /* userId */ 0)).isEmpty();
+ assertThat(extractSyntheticRelativePathSegements(
+ "/storage/emulated/0/.transforms/synthetic",
+ /* userId */ 0)).isEmpty();
+
+ assertThat(extractSyntheticRelativePathSegements(
+ "/storage/emulated/0/.transforms/synthetic/picker",
+ /* userId */ 0)).containsExactly("picker").inOrder();
+ assertThat(extractSyntheticRelativePathSegements(
+ "/storage/emulated/0/.transforms/synthetic/picker/",
+ /* userId */ 0)).containsExactly("picker").inOrder();
+
+ assertThat(extractSyntheticRelativePathSegements(
+ "/storage/emulated/0/.transforms/synthetic/picker/foo",
+ /* userId */ 0)).containsExactly("picker", "foo").inOrder();
+ assertThat(extractSyntheticRelativePathSegements(
+ "/storage/emulated/0/.transforms/synthetic/picker//foo/",
+ /* userId */ 0)).containsExactly("picker", "foo").inOrder();
+
+ assertThat(extractSyntheticRelativePathSegements(
+ "/storage/emulated/0/.transforms/synthetic/picker/foo/com.bar",
+ /* userId */ 0)).containsExactly("picker", "foo", "com.bar").inOrder();
+ }
+}